@budibase/backend-core 2.11.37 → 2.11.39
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/dist/index.js +324 -276
- package/dist/index.js.map +3 -3
- package/dist/index.js.meta.json +1 -1
- package/dist/package.json +4 -4
- package/dist/plugins.js.map +1 -1
- package/dist/plugins.js.meta.json +1 -1
- package/dist/src/cache/appMetadata.d.ts +5 -5
- package/dist/src/cache/appMetadata.js +5 -5
- package/dist/src/cache/user.d.ts +5 -5
- package/dist/src/cache/user.js +5 -5
- package/dist/src/cache/writethrough.d.ts +1 -1
- package/dist/src/cache/writethrough.js +2 -2
- package/dist/src/cache/writethrough.js.map +1 -1
- package/dist/src/configs/configs.d.ts +1 -1
- package/dist/src/configs/configs.js +1 -1
- package/dist/src/context/mainContext.d.ts +1 -1
- package/dist/src/context/mainContext.js +1 -1
- package/dist/src/db/Replication.d.ts +4 -4
- package/dist/src/db/Replication.js +4 -4
- package/dist/src/db/lucene.d.ts +8 -8
- package/dist/src/db/lucene.js +12 -12
- package/dist/src/db/utils.d.ts +1 -1
- package/dist/src/db/utils.js +1 -1
- package/dist/src/docIds/conversions.d.ts +1 -1
- package/dist/src/docIds/conversions.js +1 -1
- package/dist/src/docIds/ids.d.ts +11 -11
- package/dist/src/docIds/ids.js +11 -11
- package/dist/src/docIds/params.d.ts +8 -8
- package/dist/src/docIds/params.js +8 -8
- package/dist/src/helpers.d.ts +2 -2
- package/dist/src/helpers.js +2 -2
- package/dist/src/middleware/passport/local.d.ts +4 -4
- package/dist/src/middleware/passport/local.js +4 -4
- package/dist/src/middleware/passport/sso/oidc.js +11 -11
- package/dist/src/middleware/passport/utils.d.ts +3 -3
- package/dist/src/middleware/passport/utils.js +3 -3
- package/dist/src/objectStore/buckets/app.d.ts +3 -3
- package/dist/src/objectStore/buckets/app.js +3 -3
- package/dist/src/objectStore/objectStore.d.ts +3 -3
- package/dist/src/objectStore/objectStore.js +3 -3
- package/dist/src/queue/inMemoryQueue.d.ts +6 -6
- package/dist/src/queue/inMemoryQueue.js +9 -9
- package/dist/src/redis/redis.js +1 -1
- package/dist/src/security/permissions.d.ts +2 -2
- package/dist/src/security/permissions.js +2 -2
- package/dist/src/security/roles.d.ts +6 -6
- package/dist/src/security/roles.js +6 -6
- package/dist/src/users/db.d.ts +1 -1
- package/dist/src/users/db.js +12 -5
- package/dist/src/users/db.js.map +1 -1
- package/dist/src/users/users.d.ts +2 -1
- package/dist/src/users/users.js +20 -2
- package/dist/src/users/users.js.map +1 -1
- package/dist/src/users/utils.d.ts +1 -0
- package/dist/src/users/utils.js +2 -1
- package/dist/src/users/utils.js.map +1 -1
- package/dist/src/utils/utils.d.ts +11 -11
- package/dist/src/utils/utils.js +11 -11
- package/dist/tests/core/utilities/structures/licenses.js +13 -0
- package/dist/tests/core/utilities/structures/licenses.js.map +1 -1
- package/dist/tests/core/utilities/structures/quotas.d.ts +1 -1
- package/dist/tests/core/utilities/structures/quotas.js +3 -2
- package/dist/tests/core/utilities/structures/quotas.js.map +1 -1
- package/package.json +4 -4
- package/src/cache/appMetadata.ts +5 -5
- package/src/cache/user.ts +5 -5
- package/src/cache/writethrough.ts +2 -2
- package/src/configs/configs.ts +1 -1
- package/src/context/mainContext.ts +1 -1
- package/src/db/Replication.ts +4 -4
- package/src/db/lucene.ts +12 -12
- package/src/db/utils.ts +1 -1
- package/src/docIds/conversions.ts +1 -1
- package/src/docIds/ids.ts +11 -11
- package/src/docIds/params.ts +8 -8
- package/src/helpers.ts +2 -2
- package/src/middleware/passport/local.ts +4 -4
- package/src/middleware/passport/sso/oidc.ts +11 -11
- package/src/middleware/passport/utils.ts +3 -3
- package/src/objectStore/buckets/app.ts +3 -3
- package/src/objectStore/objectStore.ts +3 -3
- package/src/queue/inMemoryQueue.ts +9 -9
- package/src/redis/redis.ts +1 -1
- package/src/security/permissions.ts +2 -2
- package/src/security/roles.ts +6 -6
- package/src/users/db.ts +63 -48
- package/src/users/users.ts +16 -2
- package/src/users/utils.ts +1 -0
- package/src/utils/utils.ts +11 -11
- package/tests/core/users/users.spec.js +54 -0
- package/tests/core/utilities/structures/licenses.ts +13 -0
- package/tests/core/utilities/structures/quotas.ts +3 -2
package/src/users/db.ts
CHANGED
|
@@ -21,17 +21,21 @@ import {
|
|
|
21
21
|
User,
|
|
22
22
|
UserStatus,
|
|
23
23
|
UserGroup,
|
|
24
|
-
ContextUser,
|
|
25
24
|
} from "@budibase/types"
|
|
26
25
|
import {
|
|
27
26
|
getAccountHolderFromUserIds,
|
|
28
27
|
isAdmin,
|
|
28
|
+
isCreator,
|
|
29
29
|
validateUniqueUser,
|
|
30
30
|
} from "./utils"
|
|
31
31
|
import { searchExistingEmails } from "./lookup"
|
|
32
32
|
import { hash } from "../utils"
|
|
33
33
|
|
|
34
|
-
type QuotaUpdateFn = (
|
|
34
|
+
type QuotaUpdateFn = (
|
|
35
|
+
change: number,
|
|
36
|
+
creatorsChange: number,
|
|
37
|
+
cb?: () => Promise<any>
|
|
38
|
+
) => Promise<any>
|
|
35
39
|
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
|
|
36
40
|
type FeatureFn = () => Promise<Boolean>
|
|
37
41
|
type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
|
|
@@ -135,7 +139,7 @@ export class UserDB {
|
|
|
135
139
|
if (!fullUser.roles) {
|
|
136
140
|
fullUser.roles = {}
|
|
137
141
|
}
|
|
138
|
-
// add the active status to a user if
|
|
142
|
+
// add the active status to a user if it's not provided
|
|
139
143
|
if (fullUser.status == null) {
|
|
140
144
|
fullUser.status = UserStatus.ACTIVE
|
|
141
145
|
}
|
|
@@ -246,7 +250,8 @@ export class UserDB {
|
|
|
246
250
|
}
|
|
247
251
|
|
|
248
252
|
const change = dbUser ? 0 : 1 // no change if there is existing user
|
|
249
|
-
|
|
253
|
+
const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0
|
|
254
|
+
return UserDB.quotas.addUsers(change, creatorsChange, async () => {
|
|
250
255
|
await validateUniqueUser(email, tenantId)
|
|
251
256
|
|
|
252
257
|
let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser)
|
|
@@ -308,6 +313,7 @@ export class UserDB {
|
|
|
308
313
|
|
|
309
314
|
let usersToSave: any[] = []
|
|
310
315
|
let newUsers: any[] = []
|
|
316
|
+
let newCreators: any[] = []
|
|
311
317
|
|
|
312
318
|
const emails = newUsersRequested.map((user: User) => user.email)
|
|
313
319
|
const existingEmails = await searchExistingEmails(emails)
|
|
@@ -328,59 +334,66 @@ export class UserDB {
|
|
|
328
334
|
}
|
|
329
335
|
newUser.userGroups = groups
|
|
330
336
|
newUsers.push(newUser)
|
|
337
|
+
if (isCreator(newUser)) {
|
|
338
|
+
newCreators.push(newUser)
|
|
339
|
+
}
|
|
331
340
|
}
|
|
332
341
|
|
|
333
342
|
const account = await accountSdk.getAccountByTenantId(tenantId)
|
|
334
|
-
return UserDB.quotas.addUsers(
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
return UserDB.quotas.addUsers(
|
|
344
|
+
newUsers.length,
|
|
345
|
+
newCreators.length,
|
|
346
|
+
async () => {
|
|
347
|
+
// create the promises array that will be called by bulkDocs
|
|
348
|
+
newUsers.forEach((user: any) => {
|
|
349
|
+
usersToSave.push(
|
|
350
|
+
UserDB.buildUser(
|
|
351
|
+
user,
|
|
352
|
+
{
|
|
353
|
+
hashPassword: true,
|
|
354
|
+
requirePassword: user.requirePassword,
|
|
355
|
+
},
|
|
356
|
+
tenantId,
|
|
357
|
+
undefined, // no dbUser
|
|
358
|
+
account
|
|
359
|
+
)
|
|
347
360
|
)
|
|
348
|
-
)
|
|
349
|
-
})
|
|
361
|
+
})
|
|
350
362
|
|
|
351
|
-
|
|
352
|
-
|
|
363
|
+
const usersToBulkSave = await Promise.all(usersToSave)
|
|
364
|
+
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
|
|
353
365
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const saved = usersToBulkSave.map(user => {
|
|
363
|
-
return {
|
|
364
|
-
_id: user._id,
|
|
365
|
-
email: user.email,
|
|
366
|
+
// Post-processing of bulk added users, e.g. events and cache operations
|
|
367
|
+
for (const user of usersToBulkSave) {
|
|
368
|
+
// TODO: Refactor to bulk insert users into the info db
|
|
369
|
+
// instead of relying on looping tenant creation
|
|
370
|
+
await platform.users.addUser(tenantId, user._id, user.email)
|
|
371
|
+
await eventHelpers.handleSaveEvents(user, undefined)
|
|
366
372
|
}
|
|
367
|
-
})
|
|
368
373
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
374
|
+
const saved = usersToBulkSave.map(user => {
|
|
375
|
+
return {
|
|
376
|
+
_id: user._id,
|
|
377
|
+
email: user.email,
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// now update the groups
|
|
382
|
+
if (Array.isArray(saved) && groups) {
|
|
383
|
+
const groupPromises = []
|
|
384
|
+
const createdUserIds = saved.map(user => user._id)
|
|
385
|
+
for (let groupId of groups) {
|
|
386
|
+
groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
|
|
387
|
+
}
|
|
388
|
+
await Promise.all(groupPromises)
|
|
375
389
|
}
|
|
376
|
-
await Promise.all(groupPromises)
|
|
377
|
-
}
|
|
378
390
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
391
|
+
return {
|
|
392
|
+
successful: saved,
|
|
393
|
+
unsuccessful,
|
|
394
|
+
}
|
|
382
395
|
}
|
|
383
|
-
|
|
396
|
+
)
|
|
384
397
|
}
|
|
385
398
|
|
|
386
399
|
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
|
|
@@ -420,11 +433,12 @@ export class UserDB {
|
|
|
420
433
|
_deleted: true,
|
|
421
434
|
}))
|
|
422
435
|
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
|
|
436
|
+
const creatorsToDelete = usersToDelete.filter(isCreator)
|
|
423
437
|
|
|
424
|
-
await UserDB.quotas.removeUsers(toDelete.length)
|
|
425
438
|
for (let user of usersToDelete) {
|
|
426
439
|
await bulkDeleteProcessing(user)
|
|
427
440
|
}
|
|
441
|
+
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length)
|
|
428
442
|
|
|
429
443
|
// Build Response
|
|
430
444
|
// index users by id
|
|
@@ -473,7 +487,8 @@ export class UserDB {
|
|
|
473
487
|
|
|
474
488
|
await db.remove(userId, dbUser._rev)
|
|
475
489
|
|
|
476
|
-
|
|
490
|
+
const creatorsToDelete = isCreator(dbUser) ? 1 : 0
|
|
491
|
+
await UserDB.quotas.removeUsers(1, creatorsToDelete)
|
|
477
492
|
await eventHelpers.handleDeleteEvents(dbUser)
|
|
478
493
|
await cache.user.invalidateUser(userId)
|
|
479
494
|
await sessions.invalidateSessions(userId, { reason: "deletion" })
|
package/src/users/users.ts
CHANGED
|
@@ -14,14 +14,15 @@ import {
|
|
|
14
14
|
} from "../db"
|
|
15
15
|
import {
|
|
16
16
|
BulkDocsResponse,
|
|
17
|
-
ContextUser,
|
|
18
17
|
SearchQuery,
|
|
19
18
|
SearchQueryOperators,
|
|
20
19
|
SearchUsersRequest,
|
|
21
20
|
User,
|
|
21
|
+
ContextUser,
|
|
22
22
|
} from "@budibase/types"
|
|
23
|
-
import * as context from "../context"
|
|
24
23
|
import { getGlobalDB } from "../context"
|
|
24
|
+
import * as context from "../context"
|
|
25
|
+
import { isCreator } from "./utils"
|
|
25
26
|
|
|
26
27
|
type GetOpts = { cleanup?: boolean }
|
|
27
28
|
|
|
@@ -283,6 +284,19 @@ export async function getUserCount() {
|
|
|
283
284
|
return response.total_rows
|
|
284
285
|
}
|
|
285
286
|
|
|
287
|
+
export async function getCreatorCount() {
|
|
288
|
+
let creators = 0
|
|
289
|
+
async function iterate(startPage?: string) {
|
|
290
|
+
const page = await paginatedUsers({ bookmark: startPage })
|
|
291
|
+
creators += page.data.filter(isCreator).length
|
|
292
|
+
if (page.hasNextPage) {
|
|
293
|
+
await iterate(page.nextPage)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
await iterate()
|
|
297
|
+
return creators
|
|
298
|
+
}
|
|
299
|
+
|
|
286
300
|
// used to remove the builder/admin permissions, for processing the
|
|
287
301
|
// user as an app user (they may have some specific role/group
|
|
288
302
|
export function removePortalUserPermissions(user: User | ContextUser) {
|
package/src/users/utils.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getAccountByTenantId } from "../accounts"
|
|
|
10
10
|
// extract from shared-core to make easily accessible from backend-core
|
|
11
11
|
export const isBuilder = sdk.users.isBuilder
|
|
12
12
|
export const isAdmin = sdk.users.isAdmin
|
|
13
|
+
export const isCreator = sdk.users.isCreator
|
|
13
14
|
export const isGlobalBuilder = sdk.users.isGlobalBuilder
|
|
14
15
|
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
|
|
15
16
|
export const hasAdminPermissions = sdk.users.hasAdminPermissions
|
package/src/utils/utils.ts
CHANGED
|
@@ -79,8 +79,8 @@ export function isPublicApiRequest(ctx: Ctx): boolean {
|
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
81
|
* Given a request tries to find the appId, which can be located in various places
|
|
82
|
-
* @param
|
|
83
|
-
* @returns
|
|
82
|
+
* @param ctx The main request body to look through.
|
|
83
|
+
* @returns If an appId was found it will be returned.
|
|
84
84
|
*/
|
|
85
85
|
export async function getAppIdFromCtx(ctx: Ctx) {
|
|
86
86
|
// look in headers
|
|
@@ -135,7 +135,7 @@ function parseAppIdFromUrl(url?: string) {
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* opens the contents of the specified encrypted JWT.
|
|
138
|
-
* @return
|
|
138
|
+
* @return the contents of the token.
|
|
139
139
|
*/
|
|
140
140
|
export function openJwt(token: string) {
|
|
141
141
|
if (!token) {
|
|
@@ -169,8 +169,8 @@ export function isValidInternalAPIKey(apiKey: string) {
|
|
|
169
169
|
|
|
170
170
|
/**
|
|
171
171
|
* Get a cookie from context, and decrypt if necessary.
|
|
172
|
-
* @param
|
|
173
|
-
* @param
|
|
172
|
+
* @param ctx The request which is to be manipulated.
|
|
173
|
+
* @param name The name of the cookie to get.
|
|
174
174
|
*/
|
|
175
175
|
export function getCookie(ctx: Ctx, name: string) {
|
|
176
176
|
const cookie = ctx.cookies.get(name)
|
|
@@ -184,10 +184,10 @@ export function getCookie(ctx: Ctx, name: string) {
|
|
|
184
184
|
|
|
185
185
|
/**
|
|
186
186
|
* Store a cookie for the request - it will not expire.
|
|
187
|
-
* @param
|
|
188
|
-
* @param
|
|
189
|
-
* @param
|
|
190
|
-
* @param
|
|
187
|
+
* @param ctx The request which is to be manipulated.
|
|
188
|
+
* @param name The name of the cookie to set.
|
|
189
|
+
* @param value The value of cookie which will be set.
|
|
190
|
+
* @param opts options like whether to sign.
|
|
191
191
|
*/
|
|
192
192
|
export function setCookie(
|
|
193
193
|
ctx: Ctx,
|
|
@@ -223,8 +223,8 @@ export function clearCookie(ctx: Ctx, name: string) {
|
|
|
223
223
|
/**
|
|
224
224
|
* Checks if the API call being made (based on the provided ctx object) is from the client. If
|
|
225
225
|
* the call is not from a client app then it is from the builder.
|
|
226
|
-
* @param
|
|
227
|
-
* @return
|
|
226
|
+
* @param ctx The koa context object to be tested.
|
|
227
|
+
* @return returns true if the call is from the client lib (a built app rather than the builder).
|
|
228
228
|
*/
|
|
229
229
|
export function isClient(ctx: Ctx) {
|
|
230
230
|
return ctx.headers[Header.TYPE] === "client"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const _ = require('lodash/fp')
|
|
2
|
+
const {structures} = require("../../../tests")
|
|
3
|
+
|
|
4
|
+
jest.mock("../../../src/context")
|
|
5
|
+
jest.mock("../../../src/db")
|
|
6
|
+
|
|
7
|
+
const context = require("../../../src/context")
|
|
8
|
+
const db = require("../../../src/db")
|
|
9
|
+
|
|
10
|
+
const {getCreatorCount} = require('../../../src/users/users')
|
|
11
|
+
|
|
12
|
+
describe("Users", () => {
|
|
13
|
+
|
|
14
|
+
let getGlobalDBMock
|
|
15
|
+
let getGlobalUserParamsMock
|
|
16
|
+
let paginationMock
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.resetAllMocks()
|
|
20
|
+
|
|
21
|
+
getGlobalDBMock = jest.spyOn(context, "getGlobalDB")
|
|
22
|
+
getGlobalUserParamsMock = jest.spyOn(db, "getGlobalUserParams")
|
|
23
|
+
paginationMock = jest.spyOn(db, "pagination")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("Retrieves the number of creators", async () => {
|
|
27
|
+
const getUsers = (offset, limit, creators = false) => {
|
|
28
|
+
const range = _.range(offset, limit)
|
|
29
|
+
const opts = creators ? {builder: {global: true}} : undefined
|
|
30
|
+
return range.map(() => structures.users.user(opts))
|
|
31
|
+
}
|
|
32
|
+
const page1Data = getUsers(0, 8)
|
|
33
|
+
const page2Data = getUsers(8, 12, true)
|
|
34
|
+
getGlobalDBMock.mockImplementation(() => ({
|
|
35
|
+
name : "fake-db",
|
|
36
|
+
allDocs: () => ({
|
|
37
|
+
rows: [...page1Data, ...page2Data]
|
|
38
|
+
})
|
|
39
|
+
}))
|
|
40
|
+
paginationMock.mockImplementationOnce(() => ({
|
|
41
|
+
data: page1Data,
|
|
42
|
+
hasNextPage: true,
|
|
43
|
+
nextPage: "1"
|
|
44
|
+
}))
|
|
45
|
+
paginationMock.mockImplementation(() => ({
|
|
46
|
+
data: page2Data,
|
|
47
|
+
hasNextPage: false,
|
|
48
|
+
nextPage: undefined
|
|
49
|
+
}))
|
|
50
|
+
const creatorsCount = await getCreatorCount()
|
|
51
|
+
expect(creatorsCount).toBe(4)
|
|
52
|
+
expect(paginationMock).toHaveBeenCalledTimes(2)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -72,6 +72,11 @@ export function quotas(): Quotas {
|
|
|
72
72
|
value: 1,
|
|
73
73
|
triggers: [],
|
|
74
74
|
},
|
|
75
|
+
creators: {
|
|
76
|
+
name: "Creators",
|
|
77
|
+
value: 1,
|
|
78
|
+
triggers: [],
|
|
79
|
+
},
|
|
75
80
|
userGroups: {
|
|
76
81
|
name: "User Groups",
|
|
77
82
|
value: 1,
|
|
@@ -118,6 +123,10 @@ export function customer(): Customer {
|
|
|
118
123
|
export function subscription(): Subscription {
|
|
119
124
|
return {
|
|
120
125
|
amount: 10000,
|
|
126
|
+
amounts: {
|
|
127
|
+
user: 10000,
|
|
128
|
+
creator: 0,
|
|
129
|
+
},
|
|
121
130
|
cancelAt: undefined,
|
|
122
131
|
currency: "usd",
|
|
123
132
|
currentPeriodEnd: 0,
|
|
@@ -126,6 +135,10 @@ export function subscription(): Subscription {
|
|
|
126
135
|
duration: PriceDuration.MONTHLY,
|
|
127
136
|
pastDueAt: undefined,
|
|
128
137
|
quantity: 0,
|
|
138
|
+
quantities: {
|
|
139
|
+
user: 0,
|
|
140
|
+
creator: 0,
|
|
141
|
+
},
|
|
129
142
|
status: "active",
|
|
130
143
|
}
|
|
131
144
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MonthlyQuotaName, QuotaUsage } from "@budibase/types"
|
|
2
2
|
|
|
3
|
-
export const usage = (): QuotaUsage => {
|
|
3
|
+
export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
|
|
4
4
|
return {
|
|
5
5
|
_id: "usage_quota",
|
|
6
6
|
quotaReset: new Date().toISOString(),
|
|
@@ -58,7 +58,8 @@ export const usage = (): QuotaUsage => {
|
|
|
58
58
|
usageQuota: {
|
|
59
59
|
apps: 0,
|
|
60
60
|
plugins: 0,
|
|
61
|
-
users
|
|
61
|
+
users,
|
|
62
|
+
creators,
|
|
62
63
|
userGroups: 0,
|
|
63
64
|
rows: 0,
|
|
64
65
|
triggers: {},
|