@jskit-ai/workspaces-core 0.2.8 → 0.2.10
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/package.json +9 -9
- package/src/server/common/repositories/workspaceInvitesRepository.js +13 -14
- package/src/server/common/repositories/workspaceMembershipsRepository.js +11 -12
- package/src/server/common/repositories/workspacesRepository.js +14 -14
- package/test/repositories.integration.test.js +125 -0
- package/test/repositoryContracts.test.js +1 -0
- package/test/workspaceInvitesRepository.test.js +25 -0
- package/test/workspaceMembershipsRepository.test.js +4 -1
- package/test/workspacesRepository.test.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/workspaces-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
"./locales/en.json": "./locales/en.json"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
26
|
-
"@jskit-ai/json-rest-api-core": "0.2.
|
|
27
|
-
"@jskit-ai/resource-core": "0.1.
|
|
28
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
29
|
-
"@jskit-ai/users-core": "0.2.
|
|
25
|
+
"@jskit-ai/database-runtime": "0.1.257",
|
|
26
|
+
"@jskit-ai/json-rest-api-core": "0.2.9",
|
|
27
|
+
"@jskit-ai/resource-core": "0.1.199",
|
|
28
|
+
"@jskit-ai/resource-crud-core": "0.1.199",
|
|
29
|
+
"@jskit-ai/users-core": "0.2.10",
|
|
30
30
|
"json-rest-schema": "^1.0.17"
|
|
31
31
|
},
|
|
32
32
|
"description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
}
|
|
199
199
|
},
|
|
200
200
|
"peerDependencies": {
|
|
201
|
-
"@jskit-ai/auth-core": "0.2.
|
|
202
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
203
|
-
"@jskit-ai/kernel": "0.2.
|
|
201
|
+
"@jskit-ai/auth-core": "0.2.8",
|
|
202
|
+
"@jskit-ai/http-runtime": "0.2.8",
|
|
203
|
+
"@jskit-ai/kernel": "0.2.8"
|
|
204
204
|
}
|
|
205
205
|
}
|
|
@@ -10,7 +10,7 @@ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
|
10
10
|
import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
11
11
|
import {
|
|
12
12
|
createJsonRestContext,
|
|
13
|
-
|
|
13
|
+
queryAllJsonRestCollectionRows
|
|
14
14
|
} from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
|
|
15
15
|
|
|
16
16
|
function normalizeInviteRecord(payload) {
|
|
@@ -121,18 +121,17 @@ function createRepository({ api }) {
|
|
|
121
121
|
updatedAt: dateColumn("updated_at")
|
|
122
122
|
}).forUpdate();
|
|
123
123
|
}
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
},
|
|
131
|
-
transaction: resolveTransaction(options),
|
|
132
|
-
format: "plain"
|
|
124
|
+
return queryAllJsonRestCollectionRows(
|
|
125
|
+
api.resources.workspaceInvites,
|
|
126
|
+
{
|
|
127
|
+
queryParams: {
|
|
128
|
+
filters,
|
|
129
|
+
...(includeWorkspace ? { include: ["workspace"] } : {})
|
|
133
130
|
},
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
transaction: resolveTransaction(options),
|
|
132
|
+
format: "plain"
|
|
133
|
+
},
|
|
134
|
+
options.context
|
|
136
135
|
);
|
|
137
136
|
}
|
|
138
137
|
|
|
@@ -283,8 +282,8 @@ function createRepository({ api }) {
|
|
|
283
282
|
options
|
|
284
283
|
);
|
|
285
284
|
|
|
286
|
-
rows
|
|
287
|
-
return normalizeInviteRecord(rows
|
|
285
|
+
// queryInvites returns the rows in id order, compared by the database, so the newest is the last.
|
|
286
|
+
return normalizeInviteRecord(rows.at(-1) || null);
|
|
288
287
|
}
|
|
289
288
|
|
|
290
289
|
async function expirePendingByWorkspaceIdAndEmail(workspaceId, email, options = {}) {
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
9
9
|
import {
|
|
10
10
|
createJsonRestContext,
|
|
11
|
-
|
|
11
|
+
queryAllJsonRestCollectionRows
|
|
12
12
|
} from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
|
|
13
13
|
import { OWNER_ROLE_ID } from "../../../shared/roles.js";
|
|
14
14
|
|
|
@@ -54,18 +54,17 @@ function createRepository({ api }) {
|
|
|
54
54
|
.filter(Boolean)
|
|
55
55
|
)
|
|
56
56
|
);
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
},
|
|
64
|
-
transaction: resolveTransaction(options),
|
|
65
|
-
format: "plain"
|
|
57
|
+
return queryAllJsonRestCollectionRows(
|
|
58
|
+
api.resources.workspaceMemberships,
|
|
59
|
+
{
|
|
60
|
+
queryParams: {
|
|
61
|
+
filters,
|
|
62
|
+
...(normalizedInclude.length > 0 ? { include: normalizedInclude } : {})
|
|
66
63
|
},
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
transaction: resolveTransaction(options),
|
|
65
|
+
format: "plain"
|
|
66
|
+
},
|
|
67
|
+
options.context
|
|
69
68
|
);
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -2,7 +2,8 @@ import { isLostDuplicateInsertRace, resolveTransaction, retryDeadlocks, toIsoStr
|
|
|
2
2
|
import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
3
|
import {
|
|
4
4
|
createJsonRestContext,
|
|
5
|
-
extractJsonRestCollectionRows
|
|
5
|
+
extractJsonRestCollectionRows,
|
|
6
|
+
queryAllJsonRestCollectionRows
|
|
6
7
|
} from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
|
|
7
8
|
|
|
8
9
|
function normalizeWorkspaceRecord(payload = null) {
|
|
@@ -180,21 +181,20 @@ function createRepository({ api }) {
|
|
|
180
181
|
return [];
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
const rows =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
},
|
|
191
|
-
include: ["workspace"]
|
|
184
|
+
const rows = await queryAllJsonRestCollectionRows(
|
|
185
|
+
api.resources.workspaceMemberships,
|
|
186
|
+
{
|
|
187
|
+
queryParams: {
|
|
188
|
+
filters: {
|
|
189
|
+
user: normalizedUserId,
|
|
190
|
+
status: "active"
|
|
192
191
|
},
|
|
193
|
-
|
|
194
|
-
format: "plain"
|
|
192
|
+
include: ["workspace"]
|
|
195
193
|
},
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
transaction: resolveTransaction(options),
|
|
195
|
+
format: "plain"
|
|
196
|
+
},
|
|
197
|
+
options.context
|
|
198
198
|
);
|
|
199
199
|
|
|
200
200
|
const workspaces = rows
|
|
@@ -17,6 +17,12 @@ import { createRepository as createWorkspacesRepository } from "../src/server/co
|
|
|
17
17
|
import { createRepository as createMembershipsRepository } from "../src/server/common/repositories/workspaceMembershipsRepository.js";
|
|
18
18
|
import { createRepository as createInvitesRepository } from "../src/server/common/repositories/workspaceInvitesRepository.js";
|
|
19
19
|
import { createRepository as createSettingsRepository } from "../src/server/workspaceSettings/workspaceSettingsRepository.js";
|
|
20
|
+
import { renderDefaultWorkspaceInviteEmail } from "../src/server/workspaceMembers/defaultWorkspaceInviteEmail.js";
|
|
21
|
+
import { unconfiguredWorkspaceInviteMailer } from "../src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js";
|
|
22
|
+
import { createWorkspaceInviteUrlBuilder } from "../src/server/workspaceMembers/workspaceInviteUrls.js";
|
|
23
|
+
import { createService as createMembersService } from "../src/server/workspaceMembers/workspaceMembersService.js";
|
|
24
|
+
import { createWorkspacesServerI18n } from "../src/server/i18n.js";
|
|
25
|
+
import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
|
|
20
26
|
import { verifyInvitationTransactions } from "./support/invitationTransactions.js";
|
|
21
27
|
|
|
22
28
|
const TABLES = [
|
|
@@ -232,6 +238,125 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
|
|
|
232
238
|
assert.deepEqual(await workspaces.listForUserId(member.id), []);
|
|
233
239
|
});
|
|
234
240
|
|
|
241
|
+
// Lists of more rows than json-rest-api's default page (20) and maximum page (100), written in bulk.
|
|
242
|
+
async function insertRows(table, rows) {
|
|
243
|
+
for (let start = 0; start < rows.length; start += 100) {
|
|
244
|
+
await knex(table).insert(rows.slice(start, start + 100));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function insertUsers(usernames, displayName = (username) => username) {
|
|
249
|
+
await insertRows("users", usernames.map((username) => ({
|
|
250
|
+
auth_provider: "local",
|
|
251
|
+
auth_provider_user_sid: `identity-${username}`,
|
|
252
|
+
email: `${username}@example.com`,
|
|
253
|
+
username,
|
|
254
|
+
display_name: displayName(username)
|
|
255
|
+
})));
|
|
256
|
+
const rows = await knex("users").select("id", "username");
|
|
257
|
+
return new Map(rows.map((row) => [row.username, String(row.id)]));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function insertWorkspaces(count, ownerUserId) {
|
|
261
|
+
await insertRows("workspaces", Array.from({ length: count }, (_, index) => ({
|
|
262
|
+
slug: `team-${index}`,
|
|
263
|
+
name: `Team ${index}`,
|
|
264
|
+
owner_user_id: ownerUserId,
|
|
265
|
+
is_personal: false
|
|
266
|
+
})));
|
|
267
|
+
return (await knex("workspaces").orderBy("id").select("id")).map((row) => String(row.id));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
test("a workspace's 1,050 active members are all listed, sorted by name, inside a transaction too", async () => {
|
|
271
|
+
// Inserted in an order unrelated to their names, so the pages' id order is not the name order.
|
|
272
|
+
const numbers = Array.from({ length: 1050 }, (_, index) => String((index * 7919) % 1050).padStart(4, "0"));
|
|
273
|
+
const users = await insertUsers(
|
|
274
|
+
[...numbers.map((number) => `member-${number}`), "member-left"],
|
|
275
|
+
(username) => username.replace("member-", "Member ")
|
|
276
|
+
);
|
|
277
|
+
const ownerId = users.get(`member-${numbers[0]}`);
|
|
278
|
+
const workspace = await workspaces.insert({ slug: "big-team", name: "Big Team", ownerUserId: ownerId });
|
|
279
|
+
await insertRows("workspace_memberships", [...users].map(([username, userId]) => ({
|
|
280
|
+
workspace_id: workspace.id,
|
|
281
|
+
user_id: userId,
|
|
282
|
+
role_sid: userId === ownerId ? "owner" : "member",
|
|
283
|
+
status: username === "member-left" ? "inactive" : "active"
|
|
284
|
+
})));
|
|
285
|
+
const expectedNames = numbers.map((number) => `Member ${number}`).sort();
|
|
286
|
+
|
|
287
|
+
const listed = await memberships.listActiveByWorkspaceId(workspace.id);
|
|
288
|
+
assert.equal(listed.length, 1050);
|
|
289
|
+
assert.deepEqual(listed.map(({ displayName }) => displayName), expectedNames);
|
|
290
|
+
assert.equal(new Set(listed.map(({ userId }) => userId)).size, 1050);
|
|
291
|
+
|
|
292
|
+
const membersService = createMembersService({
|
|
293
|
+
workspaceMembershipsRepository: memberships,
|
|
294
|
+
workspaceInvitesRepository: invites,
|
|
295
|
+
inviteExpiresInMs: 86400000,
|
|
296
|
+
roleCatalog: createWorkspaceRoleCatalog(),
|
|
297
|
+
workspaceInvitationsEnabled: false,
|
|
298
|
+
inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
|
|
299
|
+
workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
|
|
300
|
+
workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail,
|
|
301
|
+
i18n: createWorkspacesServerI18n(),
|
|
302
|
+
userProfilesRepository: profiles,
|
|
303
|
+
userSettingsRepository: userSettings
|
|
304
|
+
});
|
|
305
|
+
const { members } = await membersService.listMembers(workspace);
|
|
306
|
+
assert.deepEqual(members.map(({ userId }) => userId), listed.map(({ userId }) => userId));
|
|
307
|
+
assert.deepEqual(members.filter(({ isOwner }) => isOwner).map(({ userId }) => userId), [ownerId]);
|
|
308
|
+
|
|
309
|
+
await profiles.withTransaction(async (trx) => {
|
|
310
|
+
const joiner = await createProfile("Zara", { trx });
|
|
311
|
+
await memberships.upsertMembership(workspace.id, joiner.id, { roleSid: "member", status: "active" }, { trx });
|
|
312
|
+
const withJoiner = await memberships.listActiveByWorkspaceId(workspace.id, { trx });
|
|
313
|
+
assert.equal(withJoiner.length, 1051);
|
|
314
|
+
assert.equal(withJoiner.at(-1).displayName, "Zara");
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("a member of 130 workspaces has all of them listed", async () => {
|
|
319
|
+
const users = await insertUsers(["host", "roaming"]);
|
|
320
|
+
const workspaceIds = await insertWorkspaces(135, users.get("host"));
|
|
321
|
+
await insertRows("workspace_memberships", workspaceIds.map((workspaceId, index) => ({
|
|
322
|
+
workspace_id: workspaceId,
|
|
323
|
+
user_id: users.get("roaming"),
|
|
324
|
+
role_sid: "member",
|
|
325
|
+
status: index < 130 ? "active" : "inactive"
|
|
326
|
+
})));
|
|
327
|
+
const activeIds = workspaceIds.slice(0, 130);
|
|
328
|
+
|
|
329
|
+
const ids = await memberships.listActiveWorkspaceIdsByUserId(users.get("roaming"));
|
|
330
|
+
assert.deepEqual([...ids].sort(), [...activeIds].sort());
|
|
331
|
+
assert.deepEqual((await workspaces.listForUserId(users.get("roaming"))).map(({ id }) => id), activeIds);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a workspace's 120 pending invitations and an email's 110 are all listed", async () => {
|
|
335
|
+
const users = await insertUsers(["host"]);
|
|
336
|
+
const [crowdedId, ...otherIds] = await insertWorkspaces(111, users.get("host"));
|
|
337
|
+
await insertRows("workspace_invites", [
|
|
338
|
+
...Array.from({ length: 123 }, (_, index) => ({
|
|
339
|
+
workspace_id: crowdedId,
|
|
340
|
+
email: `guest-${index}@example.com`,
|
|
341
|
+
status: index < 120 ? "pending" : "accepted",
|
|
342
|
+
token_hash: `crowded-${index}`
|
|
343
|
+
})),
|
|
344
|
+
...otherIds.map((workspaceId) => ({
|
|
345
|
+
workspace_id: workspaceId,
|
|
346
|
+
email: "roaming@example.com",
|
|
347
|
+
status: "pending",
|
|
348
|
+
token_hash: `roaming-${workspaceId}`
|
|
349
|
+
}))
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
const crowded = await invites.listPendingByWorkspaceIdWithWorkspace(crowdedId);
|
|
353
|
+
assert.equal(crowded.length, 120);
|
|
354
|
+
assert.equal(new Set(crowded.map(({ email }) => email)).size, 120);
|
|
355
|
+
const roaming = await invites.listPendingByEmail("roaming@example.com");
|
|
356
|
+
assert.deepEqual(roaming.map(({ workspaceId }) => workspaceId).sort(), [...otherIds].sort());
|
|
357
|
+
assert.ok(roaming.every(({ workspaceSlug }) => workspaceSlug.startsWith("team-")));
|
|
358
|
+
});
|
|
359
|
+
|
|
235
360
|
test("workspace settings and invitation lifecycle preserve logical IDs and included workspaces", async () => {
|
|
236
361
|
const owner = await createProfile("Ada");
|
|
237
362
|
const workspace = await workspaces.insert({ slug: "invitations", name: "Invitations", ownerUserId: owner.id });
|
|
@@ -55,6 +55,7 @@ for (const [name, createRepository, resourceName, write] of duplicateRecoveryCas
|
|
|
55
55
|
});
|
|
56
56
|
const api = createApiStub();
|
|
57
57
|
api.resources[resourceName] = {
|
|
58
|
+
vars: { queryMaxLimit: 100 },
|
|
58
59
|
async query() {
|
|
59
60
|
assert.equal(writeStarted, false, "A failed transaction cannot support duplicate recovery");
|
|
60
61
|
reads += 1;
|
|
@@ -55,6 +55,7 @@ function createWorkspaceInvitesApiStub({
|
|
|
55
55
|
async transaction(work) { return work(trx); },
|
|
56
56
|
resources: {
|
|
57
57
|
workspaceInvites: {
|
|
58
|
+
vars: { queryMaxLimit: 100 },
|
|
58
59
|
async query({ queryParams, format }) {
|
|
59
60
|
assert.equal(format, "plain");
|
|
60
61
|
const filters = queryParams?.filters || {};
|
|
@@ -152,6 +153,30 @@ test("workspaceInvitesRepository.insert preserves expiresAt and relationship fie
|
|
|
152
153
|
assert.equal(typeof state.postPayload.data.expiresAt, "string");
|
|
153
154
|
});
|
|
154
155
|
|
|
156
|
+
test("workspaceInvitesRepository.insert recovers a lost duplicate race with the newest pending invite by id, 10 after 9", async () => {
|
|
157
|
+
const pendingInvite = (id) => ({
|
|
158
|
+
id,
|
|
159
|
+
workspace: { id: "7" },
|
|
160
|
+
email: "ada@example.com",
|
|
161
|
+
roleSid: "member",
|
|
162
|
+
status: "pending",
|
|
163
|
+
tokenHash: `token-${id}`
|
|
164
|
+
});
|
|
165
|
+
// In id order, as the resource returns them.
|
|
166
|
+
const { api } = createWorkspaceInvitesApiStub({ rows: [pendingInvite("9"), pendingInvite("10")] });
|
|
167
|
+
api.resources.workspaceInvites.post = async () => {
|
|
168
|
+
throw Object.assign(new Error("Duplicate invite"), {
|
|
169
|
+
transactionOutcome: "rolledBack",
|
|
170
|
+
cause: { code: "ER_DUP_ENTRY" }
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
const repository = createRepository({ api });
|
|
174
|
+
|
|
175
|
+
const invite = await repository.insert({ workspaceId: "7", email: "ada@example.com", tokenHash: "token-new" });
|
|
176
|
+
|
|
177
|
+
assert.equal(invite.id, "10");
|
|
178
|
+
});
|
|
179
|
+
|
|
155
180
|
test("workspaceInvitesRepository.findPendingByTokenHash reads from the canonical invite resource without workspace data", async () => {
|
|
156
181
|
const { api } = createWorkspaceInvitesApiStub({
|
|
157
182
|
rows: [
|
|
@@ -43,6 +43,7 @@ function createWorkspaceMembershipsApiStub({
|
|
|
43
43
|
const api = {
|
|
44
44
|
resources: {
|
|
45
45
|
workspaceMemberships: {
|
|
46
|
+
vars: { queryMaxLimit: 100 },
|
|
46
47
|
async query({ queryParams, format }) {
|
|
47
48
|
assert.equal(format, "plain");
|
|
48
49
|
state.queryCalls.push(queryParams || {});
|
|
@@ -280,7 +281,9 @@ test("workspaceMembershipsRepository.listActiveWorkspaceIdsByUserId returns norm
|
|
|
280
281
|
user: "9",
|
|
281
282
|
status: "active"
|
|
282
283
|
},
|
|
283
|
-
include: ["workspace"]
|
|
284
|
+
include: ["workspace"],
|
|
285
|
+
sort: ["id"],
|
|
286
|
+
page: { size: 100 }
|
|
284
287
|
}
|
|
285
288
|
]);
|
|
286
289
|
});
|