@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.2.8",
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.256",
26
- "@jskit-ai/json-rest-api-core": "0.2.7",
27
- "@jskit-ai/resource-core": "0.1.198",
28
- "@jskit-ai/resource-crud-core": "0.1.198",
29
- "@jskit-ai/users-core": "0.2.8",
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.7",
202
- "@jskit-ai/http-runtime": "0.2.7",
203
- "@jskit-ai/kernel": "0.2.7"
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
- extractJsonRestCollectionRows
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 extractJsonRestCollectionRows(
125
- await api.resources.workspaceInvites.query(
126
- {
127
- queryParams: {
128
- filters,
129
- ...(includeWorkspace ? { include: ["workspace"] } : {})
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
- createJsonRestContext(options.context)
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.sort((left, right) => String(right.id || "").localeCompare(String(left.id || "")));
287
- return normalizeInviteRecord(rows[0] || null);
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
- extractJsonRestCollectionRows
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 extractJsonRestCollectionRows(
58
- await api.resources.workspaceMemberships.query(
59
- {
60
- queryParams: {
61
- filters,
62
- ...(normalizedInclude.length > 0 ? { include: normalizedInclude } : {})
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
- createJsonRestContext(options.context)
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 = extractJsonRestCollectionRows(
184
- await api.resources.workspaceMemberships.query(
185
- {
186
- queryParams: {
187
- filters: {
188
- user: normalizedUserId,
189
- status: "active"
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
- transaction: resolveTransaction(options),
194
- format: "plain"
192
+ include: ["workspace"]
195
193
  },
196
- createJsonRestContext(options.context)
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
  });
@@ -125,6 +125,7 @@ function createWorkspacesApiStub({
125
125
  }
126
126
  },
127
127
  workspaceMemberships: {
128
+ vars: { queryMaxLimit: 100 },
128
129
  async query({ queryParams, format }) {
129
130
  assert.equal(format, "plain");
130
131
  const filters = queryParams?.filters || {};