@pikku/redis 0.12.12 → 0.12.14
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/CHANGELOG.md +406 -0
- package/LICENSE +21 -0
- package/README.md +15 -176
- package/dist/src/redis-secret-service.d.ts +4 -0
- package/dist/src/redis-secret-service.js +55 -16
- package/dist/src/redis-secret-service.js.map +1 -1
- package/dist/src/redis-workflow-run-service.d.ts +0 -5
- package/dist/src/redis-workflow-run-service.js +0 -28
- package/dist/src/redis-workflow-run-service.js.map +1 -1
- package/dist/src/redis-workflow-service.d.ts +1 -6
- package/dist/src/redis-workflow-service.js +15 -53
- package/dist/src/redis-workflow-service.js.map +1 -1
- package/package.json +3 -2
- package/src/redis-secret-service.test.ts +72 -0
- package/src/redis-secret-service.ts +75 -16
- package/src/redis-workflow-run-service.ts +0 -42
- package/src/redis-workflow-service.test.ts +115 -0
- package/src/redis-workflow-service.ts +47 -85
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { SecretService } from '@pikku/core/services'
|
|
2
2
|
import { Redis, type RedisOptions } from 'ioredis'
|
|
3
3
|
import {
|
|
4
|
+
deriveKEK,
|
|
5
|
+
generateKEKSalt,
|
|
4
6
|
envelopeEncrypt,
|
|
5
7
|
envelopeDecrypt,
|
|
6
8
|
envelopeRewrap,
|
|
@@ -20,6 +22,8 @@ export class RedisSecretService implements SecretService {
|
|
|
20
22
|
private keyVersion: number
|
|
21
23
|
private previousKey?: string
|
|
22
24
|
private keyPrefix: string
|
|
25
|
+
private kekSalts = new Map<number, string>()
|
|
26
|
+
private keks = new Map<number, CryptoKey>()
|
|
23
27
|
|
|
24
28
|
constructor(
|
|
25
29
|
connectionOrConfig: Redis | RedisOptions | string,
|
|
@@ -50,17 +54,49 @@ export class RedisSecretService implements SecretService {
|
|
|
50
54
|
return `${this.keyPrefix}:secret:${key}`
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
private saltKey(): string {
|
|
58
|
+
return `${this.keyPrefix}:kek-salt`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async getKEKSalt(version: number): Promise<string> {
|
|
62
|
+
const cached = this.kekSalts.get(version)
|
|
63
|
+
if (cached) return cached
|
|
64
|
+
|
|
65
|
+
const field = String(version)
|
|
66
|
+
let salt = await this.redis.hget(this.saltKey(), field)
|
|
67
|
+
if (!salt) {
|
|
68
|
+
await this.redis.hsetnx(this.saltKey(), field, generateKEKSalt())
|
|
69
|
+
salt = await this.redis.hget(this.saltKey(), field)
|
|
70
|
+
if (!salt) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Failed to persist a KEK salt for key_version ${version}`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.kekSalts.set(version, salt)
|
|
78
|
+
return salt
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async getKEK(version: number): Promise<CryptoKey> {
|
|
82
|
+
const cached = this.keks.get(version)
|
|
83
|
+
if (cached) return cached
|
|
84
|
+
|
|
85
|
+
const passphrase = version === this.keyVersion ? this.key : this.previousKey
|
|
86
|
+
if (!passphrase) {
|
|
87
|
+
throw new Error(`No KEK available for key_version ${version}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
|
|
91
|
+
this.keks.set(version, kek)
|
|
92
|
+
return kek
|
|
57
93
|
}
|
|
58
94
|
|
|
59
95
|
async getSecret<T = string>(key: string): Promise<T> {
|
|
60
96
|
const data = await this.redis.hgetall(this.secretKey(key))
|
|
61
97
|
if (!data.ciphertext) throw new Error('Requested secret not found')
|
|
62
98
|
|
|
63
|
-
const kek = this.getKEK(Number(data.key_version))
|
|
99
|
+
const kek = await this.getKEK(Number(data.key_version))
|
|
64
100
|
return envelopeDecrypt<T>(kek, data.ciphertext!, data.wrapped_dek!)
|
|
65
101
|
}
|
|
66
102
|
|
|
@@ -70,7 +106,10 @@ export class RedisSecretService implements SecretService {
|
|
|
70
106
|
}
|
|
71
107
|
|
|
72
108
|
async setSecret(key: string, value: unknown): Promise<void> {
|
|
73
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
109
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
110
|
+
await this.getKEK(this.keyVersion),
|
|
111
|
+
value
|
|
112
|
+
)
|
|
74
113
|
|
|
75
114
|
await this.redis.hset(this.secretKey(key), {
|
|
76
115
|
ciphertext,
|
|
@@ -83,15 +122,32 @@ export class RedisSecretService implements SecretService {
|
|
|
83
122
|
await this.redis.del(this.secretKey(key))
|
|
84
123
|
}
|
|
85
124
|
|
|
86
|
-
async getSecrets<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
125
|
+
async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
126
|
+
keys: (keyof T & string)[]
|
|
127
|
+
): Promise<T> {
|
|
128
|
+
const rows = await Promise.all(
|
|
129
|
+
keys.map(async (key) => ({
|
|
130
|
+
key,
|
|
131
|
+
data: await this.redis.hgetall(this.secretKey(key)),
|
|
132
|
+
}))
|
|
133
|
+
)
|
|
134
|
+
|
|
90
135
|
const out: Record<string, unknown> = {}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
136
|
+
for (const { key, data } of rows) {
|
|
137
|
+
if (!data.ciphertext) continue
|
|
138
|
+
|
|
139
|
+
const keyVersion = Number(data.key_version)
|
|
140
|
+
try {
|
|
141
|
+
const kek = await this.getKEK(keyVersion)
|
|
142
|
+
out[key] = await envelopeDecrypt(kek, data.ciphertext, data.wrapped_dek!)
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Failed to decrypt secret "${key}" (key_version ${keyVersion}): ` +
|
|
146
|
+
`the configured KEK does not match the key it was wrapped under`,
|
|
147
|
+
{ cause }
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
95
151
|
return out as T
|
|
96
152
|
}
|
|
97
153
|
|
|
@@ -100,6 +156,9 @@ export class RedisSecretService implements SecretService {
|
|
|
100
156
|
throw new Error('No previousKey configured — nothing to rotate from')
|
|
101
157
|
}
|
|
102
158
|
|
|
159
|
+
const oldKEK = await this.getKEK(this.keyVersion - 1)
|
|
160
|
+
const newKEK = await this.getKEK(this.keyVersion)
|
|
161
|
+
|
|
103
162
|
const pattern = `${this.keyPrefix}:secret:*`
|
|
104
163
|
let cursor = '0'
|
|
105
164
|
let count = 0
|
|
@@ -120,8 +179,8 @@ export class RedisSecretService implements SecretService {
|
|
|
120
179
|
if (version >= this.keyVersion) continue
|
|
121
180
|
|
|
122
181
|
const newWrappedDEK = await envelopeRewrap(
|
|
123
|
-
|
|
124
|
-
|
|
182
|
+
oldKEK,
|
|
183
|
+
newKEK,
|
|
125
184
|
data.wrapped_dek!
|
|
126
185
|
)
|
|
127
186
|
await this.redis.hset(redisKey, {
|
|
@@ -167,48 +167,6 @@ export class RedisWorkflowRunService implements WorkflowRunService {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
async getAIGeneratedWorkflows(
|
|
171
|
-
agentName?: string
|
|
172
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
|
|
173
|
-
const pattern = agentName
|
|
174
|
-
? `${this.keyPrefix}:version:ai:${agentName}:*`
|
|
175
|
-
: `${this.keyPrefix}:version:ai:*`
|
|
176
|
-
const results: Array<{
|
|
177
|
-
workflowName: string
|
|
178
|
-
graphHash: string
|
|
179
|
-
graph: any
|
|
180
|
-
}> = []
|
|
181
|
-
let cursor = '0'
|
|
182
|
-
do {
|
|
183
|
-
const [nextCursor, keys] = await this.redis.scan(
|
|
184
|
-
cursor,
|
|
185
|
-
'MATCH',
|
|
186
|
-
pattern,
|
|
187
|
-
'COUNT',
|
|
188
|
-
100
|
|
189
|
-
)
|
|
190
|
-
cursor = nextCursor
|
|
191
|
-
for (const key of keys) {
|
|
192
|
-
const data = await this.redis.hgetall(key)
|
|
193
|
-
if (
|
|
194
|
-
data.source === 'ai-agent' &&
|
|
195
|
-
data.graph &&
|
|
196
|
-
data.status === 'active'
|
|
197
|
-
) {
|
|
198
|
-
const workflowName = data.workflowName
|
|
199
|
-
const graphHash = data.graphHash
|
|
200
|
-
if (!workflowName || !graphHash) continue
|
|
201
|
-
results.push({
|
|
202
|
-
workflowName,
|
|
203
|
-
graphHash,
|
|
204
|
-
graph: JSON.parse(data.graph),
|
|
205
|
-
})
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
} while (cursor !== '0')
|
|
209
|
-
return results
|
|
210
|
-
}
|
|
211
|
-
|
|
212
170
|
async deleteRun(id: string): Promise<boolean> {
|
|
213
171
|
const exists = await this.redis.exists(this.runKey(id))
|
|
214
172
|
if (!exists) return false
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import Redis from 'ioredis-mock'
|
|
4
|
+
|
|
5
|
+
import { RedisWorkflowService } from './redis-workflow-service.js'
|
|
6
|
+
|
|
7
|
+
const KEY_PREFIX = 'workflows'
|
|
8
|
+
|
|
9
|
+
let ws: RedisWorkflowService
|
|
10
|
+
let redis: any
|
|
11
|
+
|
|
12
|
+
const newRun = () =>
|
|
13
|
+
ws.createRun('flow', {}, false, 'hash', { type: 'test' } as any)
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
redis = new Redis()
|
|
17
|
+
ws = new RedisWorkflowService(redis, KEY_PREFIX)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
redis.disconnect()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('workflow run state in redis', () => {
|
|
25
|
+
test('two branches setting different keys both survive', async () => {
|
|
26
|
+
const runId = await newRun()
|
|
27
|
+
|
|
28
|
+
await Promise.all([
|
|
29
|
+
ws.updateRunState(runId, 'left', 'a'),
|
|
30
|
+
ws.updateRunState(runId, 'right', 'b'),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
await ws.getRunState(runId),
|
|
35
|
+
{ left: 'a', right: 'b' },
|
|
36
|
+
'a parallel branch overwrote the other branch’s state'
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('many concurrent writers all land', async () => {
|
|
41
|
+
const runId = await newRun()
|
|
42
|
+
const keys = Array.from({ length: 20 }, (_, i) => `k${i}`)
|
|
43
|
+
|
|
44
|
+
await Promise.all(keys.map((k, i) => ws.updateRunState(runId, k, i)))
|
|
45
|
+
|
|
46
|
+
const state = await ws.getRunState(runId)
|
|
47
|
+
assert.equal(Object.keys(state).length, keys.length)
|
|
48
|
+
keys.forEach((k, i) => assert.equal(state[k], i))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('an empty array stays an array when another key is written', async () => {
|
|
52
|
+
const runId = await newRun()
|
|
53
|
+
|
|
54
|
+
await ws.updateRunState(runId, 'items', [])
|
|
55
|
+
await ws.updateRunState(runId, 'count', 0)
|
|
56
|
+
|
|
57
|
+
const state = await ws.getRunState(runId)
|
|
58
|
+
assert.ok(
|
|
59
|
+
Array.isArray(state.items),
|
|
60
|
+
`an empty array came back as ${JSON.stringify(state.items)}`
|
|
61
|
+
)
|
|
62
|
+
assert.equal(state.count, 0)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('values keep their types, including null and nested objects', async () => {
|
|
66
|
+
const runId = await newRun()
|
|
67
|
+
|
|
68
|
+
await ws.updateRunState(runId, 'nothing', null)
|
|
69
|
+
await ws.updateRunState(runId, 'nested', { a: [1, { b: true }] })
|
|
70
|
+
await ws.updateRunState(runId, 'flag', false)
|
|
71
|
+
|
|
72
|
+
assert.deepEqual(await ws.getRunState(runId), {
|
|
73
|
+
nothing: null,
|
|
74
|
+
nested: { a: [1, { b: true }] },
|
|
75
|
+
flag: false,
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('a later write to the same key replaces it', async () => {
|
|
80
|
+
const runId = await newRun()
|
|
81
|
+
|
|
82
|
+
await ws.updateRunState(runId, 'attempt', 1)
|
|
83
|
+
await ws.updateRunState(runId, 'attempt', 2)
|
|
84
|
+
|
|
85
|
+
assert.deepEqual(await ws.getRunState(runId), { attempt: 2 })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('the state of a run that has none is empty', async () => {
|
|
89
|
+
assert.deepEqual(await ws.getRunState(await newRun()), {})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('state written before the per-key layout is still readable', async () => {
|
|
93
|
+
const runId = await newRun()
|
|
94
|
+
const redis = (ws as any).redis
|
|
95
|
+
await redis.hset(
|
|
96
|
+
(ws as any).runKey(runId),
|
|
97
|
+
'state',
|
|
98
|
+
JSON.stringify({ legacy: 'kept', shared: 'old' })
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(await ws.getRunState(runId), {
|
|
102
|
+
legacy: 'kept',
|
|
103
|
+
shared: 'old',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
await ws.updateRunState(runId, 'shared', 'new')
|
|
107
|
+
await ws.updateRunState(runId, 'fresh', 1)
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(
|
|
110
|
+
await ws.getRunState(runId),
|
|
111
|
+
{ legacy: 'kept', shared: 'new', fresh: 1 },
|
|
112
|
+
'a run in flight across the deploy lost the state it already had'
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -42,9 +42,17 @@ export class RedisWorkflowService extends PikkuWorkflowService {
|
|
|
42
42
|
super(options)
|
|
43
43
|
this.keyPrefix = keyPrefix
|
|
44
44
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
// An already-connected client is recognised by the commands it exposes
|
|
46
|
+
// rather than by `instanceof Redis`, so a compatible client that is not an
|
|
47
|
+
// ioredis subclass — a test double, a wrapper — is used as given instead of
|
|
48
|
+
// being silently ignored in favour of a fresh connection to localhost.
|
|
49
|
+
if (
|
|
50
|
+
typeof connectionOrConfig === 'object' &&
|
|
51
|
+
connectionOrConfig !== null &&
|
|
52
|
+
'hgetall' in connectionOrConfig &&
|
|
53
|
+
'hset' in connectionOrConfig
|
|
54
|
+
) {
|
|
55
|
+
this.redis = connectionOrConfig as Redis
|
|
48
56
|
this.ownsConnection = false
|
|
49
57
|
} else {
|
|
50
58
|
this.redis = new Redis(connectionOrConfig as any)
|
|
@@ -821,26 +829,9 @@ export class RedisWorkflowService extends PikkuWorkflowService {
|
|
|
821
829
|
return { completedNodeIds, failedNodeIds, branchKeys }
|
|
822
830
|
}
|
|
823
831
|
|
|
824
|
-
async
|
|
825
|
-
runId: string
|
|
826
|
-
|
|
827
|
-
): Promise<string[]> {
|
|
828
|
-
if (nodeIds.length === 0) return []
|
|
829
|
-
|
|
830
|
-
const result: string[] = []
|
|
831
|
-
|
|
832
|
-
for (const nodeId of nodeIds) {
|
|
833
|
-
const key = this.stepKey(runId, nodeId)
|
|
834
|
-
const exists = await this.redis.exists(key)
|
|
835
|
-
if (!exists) {
|
|
836
|
-
result.push(nodeId)
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
return result
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
async getStepInstances(runId: string): Promise<
|
|
832
|
+
async getStepInstances(
|
|
833
|
+
runId: string
|
|
834
|
+
): Promise<
|
|
844
835
|
Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
|
|
845
836
|
> {
|
|
846
837
|
const instances: Array<{
|
|
@@ -920,37 +911,50 @@ export class RedisWorkflowService extends PikkuWorkflowService {
|
|
|
920
911
|
)
|
|
921
912
|
}
|
|
922
913
|
|
|
914
|
+
/**
|
|
915
|
+
* Hash holding one field per state variable of a run.
|
|
916
|
+
*
|
|
917
|
+
* The whole state used to be a single JSON blob on the run hash, which made
|
|
918
|
+
* every write a read-modify-write: two parallel branches each setting their
|
|
919
|
+
* own variable would both read the blob, add their key, and write it back,
|
|
920
|
+
* so whichever wrote second silently dropped the other's variable.
|
|
921
|
+
*/
|
|
922
|
+
private runStateKey(runId: string): string {
|
|
923
|
+
return `${this.keyPrefix}:run-state:${runId}`
|
|
924
|
+
}
|
|
925
|
+
|
|
923
926
|
protected async updateRunStateImpl(
|
|
924
927
|
runId: string,
|
|
925
928
|
name: string,
|
|
926
929
|
value: unknown
|
|
927
930
|
): Promise<void> {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
await this.redis.hmset(
|
|
939
|
-
key,
|
|
940
|
-
'state',
|
|
941
|
-
JSON.stringify(state),
|
|
931
|
+
// HSET touches only the field it is named, so concurrent writers to
|
|
932
|
+
// different variables no longer race, and nothing has to parse and
|
|
933
|
+
// re-encode the values it is not writing.
|
|
934
|
+
await this.redis.hset(
|
|
935
|
+
this.runStateKey(runId),
|
|
936
|
+
name,
|
|
937
|
+
JSON.stringify(value) ?? 'null'
|
|
938
|
+
)
|
|
939
|
+
await this.redis.hset(
|
|
940
|
+
this.runKey(runId),
|
|
942
941
|
'updatedAt',
|
|
943
|
-
now.toString()
|
|
942
|
+
Date.now().toString()
|
|
944
943
|
)
|
|
945
944
|
}
|
|
946
945
|
|
|
947
946
|
async getRunState(runId: string): Promise<Record<string, unknown>> {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
947
|
+
// The blob is what a run started before the per-key layout still holds; a
|
|
948
|
+
// field written since wins, so a run in flight across the deploy keeps
|
|
949
|
+
// everything it had.
|
|
950
|
+
const blob = await this.redis.hget(this.runKey(runId), 'state')
|
|
951
|
+
const state: Record<string, unknown> = blob ? JSON.parse(blob) : {}
|
|
952
|
+
|
|
953
|
+
const fields = await this.redis.hgetall(this.runStateKey(runId))
|
|
954
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
955
|
+
state[name] = JSON.parse(value)
|
|
952
956
|
}
|
|
953
|
-
return
|
|
957
|
+
return state
|
|
954
958
|
}
|
|
955
959
|
|
|
956
960
|
private versionKey(name: string, graphHash: string): string {
|
|
@@ -1007,48 +1011,6 @@ export class RedisWorkflowService extends PikkuWorkflowService {
|
|
|
1007
1011
|
}
|
|
1008
1012
|
}
|
|
1009
1013
|
|
|
1010
|
-
async getAIGeneratedWorkflows(
|
|
1011
|
-
agentName?: string
|
|
1012
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
|
|
1013
|
-
const pattern = agentName
|
|
1014
|
-
? `${this.keyPrefix}:version:ai\\:${agentName}\\:*`
|
|
1015
|
-
: `${this.keyPrefix}:version:ai\\:*`
|
|
1016
|
-
const results: Array<{
|
|
1017
|
-
workflowName: string
|
|
1018
|
-
graphHash: string
|
|
1019
|
-
graph: any
|
|
1020
|
-
}> = []
|
|
1021
|
-
let cursor = '0'
|
|
1022
|
-
do {
|
|
1023
|
-
const [nextCursor, keys] = await this.redis.scan(
|
|
1024
|
-
cursor,
|
|
1025
|
-
'MATCH',
|
|
1026
|
-
pattern,
|
|
1027
|
-
'COUNT',
|
|
1028
|
-
100
|
|
1029
|
-
)
|
|
1030
|
-
cursor = nextCursor
|
|
1031
|
-
for (const key of keys) {
|
|
1032
|
-
const data = await this.redis.hgetall(key)
|
|
1033
|
-
if (
|
|
1034
|
-
!data.graph ||
|
|
1035
|
-
data.source !== 'ai-agent' ||
|
|
1036
|
-
data.status !== 'active'
|
|
1037
|
-
)
|
|
1038
|
-
continue
|
|
1039
|
-
const workflowName = data.workflowName
|
|
1040
|
-
const graphHash = data.graphHash
|
|
1041
|
-
if (!workflowName || !graphHash) continue
|
|
1042
|
-
results.push({
|
|
1043
|
-
workflowName,
|
|
1044
|
-
graphHash,
|
|
1045
|
-
graph: JSON.parse(data.graph),
|
|
1046
|
-
})
|
|
1047
|
-
}
|
|
1048
|
-
} while (cursor !== '0')
|
|
1049
|
-
return results
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
1014
|
async close(): Promise<void> {
|
|
1053
1015
|
if (this.ownsConnection) {
|
|
1054
1016
|
await this.redis.quit()
|