@budibase/backend-core 2.21.2 → 2.21.4

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.
@@ -1,5 +1,5 @@
1
1
  import env from "../environment"
2
- import Redis from "ioredis"
2
+ import Redis, { Cluster } from "ioredis"
3
3
  // mock-redis doesn't have any typing
4
4
  let MockRedis: any | undefined
5
5
  if (env.MOCK_REDIS) {
@@ -28,7 +28,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
28
28
 
29
29
  // for testing just generate the client once
30
30
  let CLOSED = false
31
- let CLIENTS: { [key: number]: any } = {}
31
+ const CLIENTS: Record<number, Redis> = {}
32
32
  let CONNECTED = false
33
33
 
34
34
  // mock redis always connected
@@ -36,7 +36,7 @@ if (env.MOCK_REDIS) {
36
36
  CONNECTED = true
37
37
  }
38
38
 
39
- function pickClient(selectDb: number): any {
39
+ function pickClient(selectDb: number) {
40
40
  return CLIENTS[selectDb]
41
41
  }
42
42
 
@@ -201,12 +201,15 @@ class RedisWrapper {
201
201
  key = `${db}${SEPARATOR}${key}`
202
202
  let stream
203
203
  if (CLUSTERED) {
204
- let node = this.getClient().nodes("master")
204
+ let node = (this.getClient() as never as Cluster).nodes("master")
205
205
  stream = node[0].scanStream({ match: key + "*", count: 100 })
206
206
  } else {
207
- stream = this.getClient().scanStream({ match: key + "*", count: 100 })
207
+ stream = (this.getClient() as Redis).scanStream({
208
+ match: key + "*",
209
+ count: 100,
210
+ })
208
211
  }
209
- return promisifyStream(stream, this.getClient())
212
+ return promisifyStream(stream, this.getClient() as any)
210
213
  }
211
214
 
212
215
  async keys(pattern: string) {
@@ -221,14 +224,16 @@ class RedisWrapper {
221
224
 
222
225
  async get(key: string) {
223
226
  const db = this._db
224
- let response = await this.getClient().get(addDbPrefix(db, key))
227
+ const response = await this.getClient().get(addDbPrefix(db, key))
225
228
  // overwrite the prefixed key
229
+ // @ts-ignore
226
230
  if (response != null && response.key) {
231
+ // @ts-ignore
227
232
  response.key = key
228
233
  }
229
234
  // if its not an object just return the response
230
235
  try {
231
- return JSON.parse(response)
236
+ return JSON.parse(response!)
232
237
  } catch (err) {
233
238
  return response
234
239
  }
@@ -274,13 +279,37 @@ class RedisWrapper {
274
279
  }
275
280
  }
276
281
 
282
+ async bulkStore(
283
+ data: Record<string, any>,
284
+ expirySeconds: number | null = null
285
+ ) {
286
+ const client = this.getClient()
287
+
288
+ const dataToStore = Object.entries(data).reduce((acc, [key, value]) => {
289
+ acc[addDbPrefix(this._db, key)] =
290
+ typeof value === "object" ? JSON.stringify(value) : value
291
+ return acc
292
+ }, {} as Record<string, any>)
293
+
294
+ const pipeline = client.pipeline()
295
+ pipeline.mset(dataToStore)
296
+
297
+ if (expirySeconds !== null) {
298
+ for (const key of Object.keys(dataToStore)) {
299
+ pipeline.expire(key, expirySeconds)
300
+ }
301
+ }
302
+
303
+ await pipeline.exec()
304
+ }
305
+
277
306
  async getTTL(key: string) {
278
307
  const db = this._db
279
308
  const prefixedKey = addDbPrefix(db, key)
280
309
  return this.getClient().ttl(prefixedKey)
281
310
  }
282
311
 
283
- async setExpiry(key: string, expirySeconds: number | null) {
312
+ async setExpiry(key: string, expirySeconds: number) {
284
313
  const db = this._db
285
314
  const prefixedKey = addDbPrefix(db, key)
286
315
  await this.getClient().expire(prefixedKey, expirySeconds)
@@ -295,6 +324,26 @@ class RedisWrapper {
295
324
  let items = await this.scan()
296
325
  await Promise.all(items.map((obj: any) => this.delete(obj.key)))
297
326
  }
327
+
328
+ async increment(key: string) {
329
+ const result = await this.getClient().incr(addDbPrefix(this._db, key))
330
+ if (isNaN(result)) {
331
+ throw new Error(`Redis ${key} does not contain a number`)
332
+ }
333
+ return result
334
+ }
335
+
336
+ async deleteIfValue(key: string, value: any) {
337
+ const client = this.getClient()
338
+
339
+ const luaScript = `
340
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
341
+ redis.call('DEL', KEYS[1])
342
+ end
343
+ `
344
+
345
+ await client.eval(luaScript, 1, addDbPrefix(this._db, key), value)
346
+ }
298
347
  }
299
348
 
300
349
  export default RedisWrapper
@@ -72,7 +72,7 @@ const OPTIONS: Record<keyof typeof LockType, Redlock.Options> = {
72
72
  export async function newRedlock(opts: Redlock.Options = {}) {
73
73
  const options = { ...OPTIONS.DEFAULT, ...opts }
74
74
  const redisWrapper = await getLockClient()
75
- const client = redisWrapper.getClient()
75
+ const client = redisWrapper.getClient() as any
76
76
  return new Redlock([client], options)
77
77
  }
78
78
 
@@ -0,0 +1,214 @@
1
+ import { GenericContainer, StartedTestContainer } from "testcontainers"
2
+ import { generator, structures } from "../../../tests"
3
+ import RedisWrapper from "../redis"
4
+ import { env } from "../.."
5
+
6
+ jest.setTimeout(30000)
7
+
8
+ describe("redis", () => {
9
+ let redis: RedisWrapper
10
+ let container: StartedTestContainer
11
+
12
+ beforeAll(async () => {
13
+ const container = await new GenericContainer("redis")
14
+ .withExposedPorts(6379)
15
+ .start()
16
+
17
+ env._set(
18
+ "REDIS_URL",
19
+ `${container.getHost()}:${container.getMappedPort(6379)}`
20
+ )
21
+ env._set("MOCK_REDIS", 0)
22
+ env._set("REDIS_PASSWORD", 0)
23
+ })
24
+
25
+ afterAll(() => container?.stop())
26
+
27
+ beforeEach(async () => {
28
+ redis = new RedisWrapper(structures.db.id())
29
+ await redis.init()
30
+ })
31
+
32
+ describe("store", () => {
33
+ it("a basic value can be persisted", async () => {
34
+ const key = structures.uuid()
35
+ const value = generator.word()
36
+
37
+ await redis.store(key, value)
38
+
39
+ expect(await redis.get(key)).toEqual(value)
40
+ })
41
+
42
+ it("objects can be persisted", async () => {
43
+ const key = structures.uuid()
44
+ const value = { [generator.word()]: generator.word() }
45
+
46
+ await redis.store(key, value)
47
+
48
+ expect(await redis.get(key)).toEqual(value)
49
+ })
50
+ })
51
+
52
+ describe("bulkStore", () => {
53
+ function createRandomObject(
54
+ keyLength: number,
55
+ valueGenerator: () => any = () => generator.word()
56
+ ) {
57
+ return generator
58
+ .unique(() => generator.word(), keyLength)
59
+ .reduce((acc, key) => {
60
+ acc[key] = valueGenerator()
61
+ return acc
62
+ }, {} as Record<string, string>)
63
+ }
64
+
65
+ it("a basic object can be persisted", async () => {
66
+ const data = createRandomObject(10)
67
+
68
+ await redis.bulkStore(data)
69
+
70
+ for (const [key, value] of Object.entries(data)) {
71
+ expect(await redis.get(key)).toEqual(value)
72
+ }
73
+
74
+ expect(await redis.keys("*")).toHaveLength(10)
75
+ })
76
+
77
+ it("a complex object can be persisted", async () => {
78
+ const data = {
79
+ ...createRandomObject(10, () => createRandomObject(5)),
80
+ ...createRandomObject(5),
81
+ }
82
+
83
+ await redis.bulkStore(data)
84
+
85
+ for (const [key, value] of Object.entries(data)) {
86
+ expect(await redis.get(key)).toEqual(value)
87
+ }
88
+
89
+ expect(await redis.keys("*")).toHaveLength(15)
90
+ })
91
+
92
+ it("no TTL is set by default", async () => {
93
+ const data = createRandomObject(10)
94
+
95
+ await redis.bulkStore(data)
96
+
97
+ for (const [key, value] of Object.entries(data)) {
98
+ expect(await redis.get(key)).toEqual(value)
99
+ expect(await redis.getTTL(key)).toEqual(-1)
100
+ }
101
+ })
102
+
103
+ it("a bulk store can be persisted with TTL", async () => {
104
+ const ttl = 500
105
+ const data = createRandomObject(8)
106
+
107
+ await redis.bulkStore(data, ttl)
108
+
109
+ for (const [key, value] of Object.entries(data)) {
110
+ expect(await redis.get(key)).toEqual(value)
111
+ expect(await redis.getTTL(key)).toEqual(ttl)
112
+ }
113
+
114
+ expect(await redis.keys("*")).toHaveLength(8)
115
+ })
116
+
117
+ it("setting a TTL of -1 will not persist the key", async () => {
118
+ const ttl = -1
119
+ const data = createRandomObject(5)
120
+
121
+ await redis.bulkStore(data, ttl)
122
+
123
+ for (const [key, value] of Object.entries(data)) {
124
+ expect(await redis.get(key)).toBe(null)
125
+ }
126
+
127
+ expect(await redis.keys("*")).toHaveLength(0)
128
+ })
129
+ })
130
+
131
+ describe("increment", () => {
132
+ it("can increment on a new key", async () => {
133
+ const key = structures.uuid()
134
+ const result = await redis.increment(key)
135
+ expect(result).toBe(1)
136
+ })
137
+
138
+ it("can increment multiple times", async () => {
139
+ const key = structures.uuid()
140
+ const results = [
141
+ await redis.increment(key),
142
+ await redis.increment(key),
143
+ await redis.increment(key),
144
+ await redis.increment(key),
145
+ await redis.increment(key),
146
+ ]
147
+ expect(results).toEqual([1, 2, 3, 4, 5])
148
+ })
149
+
150
+ it("can increment on a new key", async () => {
151
+ const key1 = structures.uuid()
152
+ const key2 = structures.uuid()
153
+
154
+ const result1 = await redis.increment(key1)
155
+ expect(result1).toBe(1)
156
+
157
+ const result2 = await redis.increment(key2)
158
+ expect(result2).toBe(1)
159
+ })
160
+
161
+ it("can increment multiple times in parallel", async () => {
162
+ const key = structures.uuid()
163
+ const results = await Promise.all(
164
+ Array.from({ length: 100 }).map(() => redis.increment(key))
165
+ )
166
+ expect(results).toHaveLength(100)
167
+ expect(results).toEqual(Array.from({ length: 100 }).map((_, i) => i + 1))
168
+ })
169
+
170
+ it("can increment existing set keys", async () => {
171
+ const key = structures.uuid()
172
+ await redis.store(key, 70)
173
+ await redis.increment(key)
174
+
175
+ const result = await redis.increment(key)
176
+ expect(result).toBe(72)
177
+ })
178
+
179
+ it.each([
180
+ generator.word(),
181
+ generator.bool(),
182
+ { [generator.word()]: generator.word() },
183
+ ])("cannot increment if the store value is not a number", async value => {
184
+ const key = structures.uuid()
185
+ await redis.store(key, value)
186
+
187
+ await expect(redis.increment(key)).rejects.toThrowError(
188
+ "ERR value is not an integer or out of range"
189
+ )
190
+ })
191
+ })
192
+
193
+ describe("deleteIfValue", () => {
194
+ it("can delete if the value matches", async () => {
195
+ const key = structures.uuid()
196
+ const value = generator.word()
197
+ await redis.store(key, value)
198
+
199
+ await redis.deleteIfValue(key, value)
200
+
201
+ expect(await redis.get(key)).toBeNull()
202
+ })
203
+
204
+ it("will not delete if the value does not matches", async () => {
205
+ const key = structures.uuid()
206
+ const value = generator.word()
207
+ await redis.store(key, value)
208
+
209
+ await redis.deleteIfValue(key, generator.word())
210
+
211
+ expect(await redis.get(key)).toEqual(value)
212
+ })
213
+ })
214
+ })
@@ -84,25 +84,24 @@ export function getBuiltinRoles(): { [key: string]: RoleDoc } {
84
84
  return cloneDeep(BUILTIN_ROLES)
85
85
  }
86
86
 
87
- export const BUILTIN_ROLE_ID_ARRAY = Object.values(BUILTIN_ROLES).map(
88
- role => role._id
89
- )
90
-
91
- export const BUILTIN_ROLE_NAME_ARRAY = Object.values(BUILTIN_ROLES).map(
92
- role => role.name
93
- )
87
+ export function isBuiltin(role: string) {
88
+ return getBuiltinRole(role) !== undefined
89
+ }
94
90
 
95
- export function isBuiltin(role?: string) {
96
- return BUILTIN_ROLE_ID_ARRAY.some(builtin => role?.includes(builtin))
91
+ export function getBuiltinRole(roleId: string): Role | undefined {
92
+ const role = Object.values(BUILTIN_ROLES).find(role =>
93
+ roleId.includes(role._id)
94
+ )
95
+ if (!role) {
96
+ return undefined
97
+ }
98
+ return cloneDeep(role)
97
99
  }
98
100
 
99
101
  /**
100
102
  * Works through the inheritance ranks to see how far up the builtin stack this ID is.
101
103
  */
102
- export function builtinRoleToNumber(id?: string) {
103
- if (!id) {
104
- return 0
105
- }
104
+ export function builtinRoleToNumber(id: string) {
106
105
  const builtins = getBuiltinRoles()
107
106
  const MAX = Object.values(builtins).length + 1
108
107
  if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
@@ -123,7 +122,7 @@ export function builtinRoleToNumber(id?: string) {
123
122
  /**
124
123
  * Converts any role to a number, but has to be async to get the roles from db.
125
124
  */
126
- export async function roleToNumber(id?: string) {
125
+ export async function roleToNumber(id: string) {
127
126
  if (isBuiltin(id)) {
128
127
  return builtinRoleToNumber(id)
129
128
  }
@@ -131,7 +130,7 @@ export async function roleToNumber(id?: string) {
131
130
  defaultPublic: true,
132
131
  })) as RoleDoc[]
133
132
  for (let role of hierarchy) {
134
- if (isBuiltin(role?.inherits)) {
133
+ if (role?.inherits && isBuiltin(role.inherits)) {
135
134
  return builtinRoleToNumber(role.inherits) + 1
136
135
  }
137
136
  }
@@ -161,35 +160,28 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
161
160
  * @returns The role object, which may contain an "inherits" property.
162
161
  */
163
162
  export async function getRole(
164
- roleId?: string,
163
+ roleId: string,
165
164
  opts?: { defaultPublic?: boolean }
166
- ): Promise<RoleDoc | undefined> {
167
- if (!roleId) {
168
- return undefined
169
- }
170
- let role: any = {}
165
+ ): Promise<RoleDoc> {
171
166
  // built in roles mostly come from the in-code implementation,
172
167
  // but can be extended by a doc stored about them (e.g. permissions)
173
- if (isBuiltin(roleId)) {
174
- role = cloneDeep(
175
- Object.values(BUILTIN_ROLES).find(role => role._id === roleId)
176
- )
177
- } else {
168
+ let role: RoleDoc | undefined = getBuiltinRole(roleId)
169
+ if (!role) {
178
170
  // make sure has the prefix (if it has it then it won't be added)
179
171
  roleId = prefixRoleID(roleId)
180
172
  }
181
173
  try {
182
174
  const db = getAppDB()
183
- const dbRole = await db.get(getDBRoleID(roleId))
184
- role = Object.assign(role, dbRole)
175
+ const dbRole = await db.get<RoleDoc>(getDBRoleID(roleId))
176
+ role = Object.assign(role || {}, dbRole)
185
177
  // finalise the ID
186
- role._id = getExternalRoleID(role._id, role.version)
178
+ role._id = getExternalRoleID(role._id!, role.version)
187
179
  } catch (err) {
188
180
  if (!isBuiltin(roleId) && opts?.defaultPublic) {
189
181
  return cloneDeep(BUILTIN_ROLES.PUBLIC)
190
182
  }
191
183
  // only throw an error if there is no role at all
192
- if (Object.keys(role).length === 0) {
184
+ if (!role || Object.keys(role).length === 0) {
193
185
  throw err
194
186
  }
195
187
  }
@@ -200,7 +192,7 @@ export async function getRole(
200
192
  * Simple function to get all the roles based on the top level user role ID.
201
193
  */
202
194
  async function getAllUserRoles(
203
- userRoleId?: string,
195
+ userRoleId: string,
204
196
  opts?: { defaultPublic?: boolean }
205
197
  ): Promise<RoleDoc[]> {
206
198
  // admins have access to all roles
@@ -226,7 +218,7 @@ async function getAllUserRoles(
226
218
  }
227
219
 
228
220
  export async function getUserRoleIdHierarchy(
229
- userRoleId?: string
221
+ userRoleId: string
230
222
  ): Promise<string[]> {
231
223
  const roles = await getUserRoleHierarchy(userRoleId)
232
224
  return roles.map(role => role._id!)
@@ -241,7 +233,7 @@ export async function getUserRoleIdHierarchy(
241
233
  * highest level of access and the last being the lowest level.
242
234
  */
243
235
  export async function getUserRoleHierarchy(
244
- userRoleId?: string,
236
+ userRoleId: string,
245
237
  opts?: { defaultPublic?: boolean }
246
238
  ) {
247
239
  // special case, if they don't have a role then they are a public user
@@ -265,9 +257,9 @@ export function checkForRoleResourceArray(
265
257
  return rolePerms
266
258
  }
267
259
 
268
- export async function getAllRoleIds(appId?: string) {
260
+ export async function getAllRoleIds(appId: string): Promise<string[]> {
269
261
  const roles = await getAllRoles(appId)
270
- return roles.map(role => role._id)
262
+ return roles.map(role => role._id!)
271
263
  }
272
264
 
273
265
  /**
@@ -18,7 +18,7 @@ export const account = (partial: Partial<Account> = {}): Account => {
18
18
  return {
19
19
  accountId: uuid(),
20
20
  tenantId: generator.word(),
21
- email: generator.email(),
21
+ email: generator.email({ domain: "example.com" }),
22
22
  tenantName: generator.word(),
23
23
  hosting: Hosting.SELF,
24
24
  createdAt: Date.now(),
@@ -13,7 +13,7 @@ interface CreateUserRequestFields {
13
13
  export function createUserRequest(userData?: Partial<CreateUserRequestFields>) {
14
14
  const defaultValues = {
15
15
  externalId: uuid(),
16
- email: generator.email(),
16
+ email: `${uuid()}@example.com`,
17
17
  firstName: generator.first(),
18
18
  lastName: generator.last(),
19
19
  username: generator.name(),