@jskit-ai/workspaces-core 0.2.13 → 0.2.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 CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A workspace request's auth policy and its action context share one workspace context resolution: `resolveWorkspaceContextForUserBySlug` called with the same `request` (the auth policy resolver now passes it, as the action context contributor already did), user, slug and membership requirement reads the workspace, membership and workspace settings once instead of once per check, so a workspace action with a permission no longer repeats those reads before its handler. Calls without a request, or in a transaction, resolve on their own as before.
6
+ - `workspaceMembershipsRepository.listActiveWorkspaceIdsByUserIds(userIds, options)` answers many users' active workspace ids as a `Map` in one query per 1,000 users, reading the memberships' `workspace_id` instead of including each workspace. The memberships resource gains the `users` filter (`user_id in [...]`) it uses. The realtime audience contributor (`workspaces.memberships`) answers realtime's bulk `resolveActorsScopes(actorIds)` with it, so a workspace event costs one memberships query, not one per online user.
7
+ - A new workspace's slug (`base`, then `base-2` to `base-1000`) is chosen with one read of every taken candidate through `workspacesRepository.listSlugsIn(slugs, options)` and the workspace resource's new `slugs` filter (`in`), instead of one `findBySlug` per taken candidate, so a common name costs the same statements after 10 namesakes as after 200. The chosen slug and the error when all 1,000 are taken are unchanged.
5
8
  - `services.members.getMember(workspace, userId, options)` answers one active member's summary, the same as their `listMembers` entry, or `null` for a removed or unknown member. It reads that one membership through `workspaceMembershipsRepository.findActiveByWorkspaceIdAndUserId`, so a per-member lookup (a notification's recipient check, for example) costs the same few statements in a workspace of 10 or 1,000 members instead of listing them all.
6
9
  - The workspace, settings, member and invitation field messages are sent in the coded form `{ code, message, params }`, so their field errors translate. The English is unchanged.
7
10
  - The resources' save, delete and validation feedback names catalog keys under `jskit.workspaces.feedback.*` instead of plain English; JSKIT's forms render them with `text()`, so the English shown is unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -22,11 +22,11 @@
22
22
  "./locales/en.json": "./locales/en.json"
23
23
  },
24
24
  "dependencies": {
25
- "@jskit-ai/database-runtime": "0.1.260",
26
- "@jskit-ai/json-rest-api-core": "0.2.12",
27
- "@jskit-ai/resource-core": "0.1.202",
28
- "@jskit-ai/resource-crud-core": "0.1.202",
29
- "@jskit-ai/users-core": "0.2.13",
25
+ "@jskit-ai/database-runtime": "0.1.261",
26
+ "@jskit-ai/json-rest-api-core": "0.2.13",
27
+ "@jskit-ai/resource-core": "0.1.203",
28
+ "@jskit-ai/resource-crud-core": "0.1.203",
29
+ "@jskit-ai/users-core": "0.2.14",
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.11",
202
- "@jskit-ai/http-runtime": "0.2.11",
203
- "@jskit-ai/kernel": "0.2.11"
201
+ "@jskit-ai/auth-core": "0.2.12",
202
+ "@jskit-ai/http-runtime": "0.2.12",
203
+ "@jskit-ai/kernel": "0.2.12"
204
204
  }
205
205
  }
@@ -9,16 +9,20 @@ import {
9
9
 
10
10
  /**
11
11
  * Every active membership puts the actor in the workspace audience and in its
12
- * own workspace-user audience.
12
+ * own workspace-user audience. All the actors' memberships are one bulk read.
13
13
  * @param {import("./types.js").WorkspaceMembershipsRepository} workspaceMemberships
14
- * @param {string} actorId
14
+ * @param {string[]} actorIds
15
15
  */
16
- async function resolveWorkspaceRealtimeScopes(workspaceMemberships, actorId) {
17
- const workspaceIds = await workspaceMemberships.listActiveWorkspaceIdsByUserId(actorId);
18
- return workspaceIds.flatMap((workspaceId) => [
19
- { kind: "workspace", id: workspaceId },
20
- { kind: "workspace_user", workspaceId, userId: actorId }
21
- ]);
16
+ async function resolveWorkspaceRealtimeScopes(workspaceMemberships, actorIds) {
17
+ const workspaceIdsByActorId = await workspaceMemberships.listActiveWorkspaceIdsByUserIds(actorIds);
18
+ const scopesByActorId = new Map();
19
+ for (const [actorId, workspaceIds] of workspaceIdsByActorId) {
20
+ scopesByActorId.set(actorId, workspaceIds.flatMap((workspaceId) => [
21
+ { kind: "workspace", id: workspaceId },
22
+ { kind: "workspace_user", workspaceId, userId: actorId }
23
+ ]));
24
+ }
25
+ return scopesByActorId;
22
26
  }
23
27
 
24
28
  const WorkspacesIntegrationsProvider = defineProvider({
@@ -88,9 +92,9 @@ const WorkspacesIntegrationsProvider = defineProvider({
88
92
  if (realtime) {
89
93
  realtime.registerAudienceContributor({
90
94
  id: "workspaces.memberships",
91
- resolveActorScopes: (actorId) => resolveWorkspaceRealtimeScopes(
95
+ resolveActorsScopes: (actorIds) => resolveWorkspaceRealtimeScopes(
92
96
  workspaces.repositories.workspaceMemberships,
93
- actorId
97
+ actorIds
94
98
  )
95
99
  });
96
100
  }
@@ -30,7 +30,7 @@ function createWorkspaceAuthPolicyContextResolver({ workspaceService, workspaceM
30
30
  const { workspace, membership, permissions } = await workspaceService.resolveWorkspaceContextForUserBySlug(
31
31
  actor,
32
32
  workspaceSlug,
33
- { requireMembership }
33
+ { requireMembership, request }
34
34
  );
35
35
 
36
36
  return { workspace, membership, permissions };
@@ -12,6 +12,9 @@ import {
12
12
  } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
13
13
  import { OWNER_ROLE_ID } from "../../../shared/roles.js";
14
14
 
15
+ // A bulk read's users per query, so its "in" list stays well inside every database's limits.
16
+ const USER_IDS_PER_QUERY = 1000;
17
+
15
18
  function normalizeMembershipRecord(payload) {
16
19
  if (!payload) {
17
20
  return null;
@@ -278,6 +281,39 @@ function createRepository({ api }) {
278
281
  .filter(Boolean);
279
282
  }
280
283
 
284
+ /**
285
+ * Each user's active workspace ids, as listActiveWorkspaceIdsByUserId lists them, in one
286
+ * query per 1,000 users: its cost does not grow with the users asked about. It reads the
287
+ * memberships' workspace_id column instead of including each workspace.
288
+ * @param {Iterable<string | number>} userIds
289
+ * @param {object} [options]
290
+ * @returns {Promise<Map<string, string[]>>} Every asked user, with no workspaces when they have none.
291
+ */
292
+ async function listActiveWorkspaceIdsByUserIds(userIds, options = {}) {
293
+ const workspaceIdsByUserId = new Map();
294
+ for (const userId of userIds) {
295
+ const normalizedUserId = normalizeRecordId(userId, { fallback: null });
296
+ if (normalizedUserId) workspaceIdsByUserId.set(normalizedUserId, []);
297
+ }
298
+
299
+ const normalizedUserIds = [...workspaceIdsByUserId.keys()];
300
+ for (let start = 0; start < normalizedUserIds.length; start += USER_IDS_PER_QUERY) {
301
+ const rows = await queryMemberships(
302
+ {
303
+ users: normalizedUserIds.slice(start, start + USER_IDS_PER_QUERY),
304
+ status: "active"
305
+ },
306
+ options
307
+ );
308
+ for (const row of rows) {
309
+ const userId = normalizeDbRecordId(row?.user?.id || row?.userId, { fallback: null });
310
+ const workspaceId = normalizeDbRecordId(row?.workspace?.id || row?.workspaceId, { fallback: null });
311
+ if (workspaceId) workspaceIdsByUserId.get(userId)?.push(workspaceId);
312
+ }
313
+ }
314
+ return workspaceIdsByUserId;
315
+ }
316
+
281
317
  return Object.freeze({
282
318
  withTransaction,
283
319
  findByWorkspaceIdAndUserId,
@@ -285,7 +321,8 @@ function createRepository({ api }) {
285
321
  upsertMembership,
286
322
  listActiveByWorkspaceId,
287
323
  findActiveByWorkspaceIdAndUserId,
288
- listActiveWorkspaceIdsByUserId
324
+ listActiveWorkspaceIdsByUserId,
325
+ listActiveWorkspaceIdsByUserIds
289
326
  });
290
327
  }
291
328
 
@@ -1,5 +1,5 @@
1
1
  import { isLostDuplicateInsertRace, resolveTransaction, retryDeadlocks, toIsoString } from "@jskit-ai/database-runtime/shared";
2
- import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import {
4
4
  createJsonRestContext,
5
5
  extractJsonRestCollectionRows,
@@ -63,6 +63,36 @@ function createRepository({ api }) {
63
63
  return queryFirst({ slug: normalizedSlug }, options);
64
64
  }
65
65
 
66
+ /**
67
+ * Which of these slugs workspaces already hold, in one read however many do.
68
+ * @param {string[]} slugs
69
+ * @param {object} [options]
70
+ * @returns {Promise<Set<string>>}
71
+ */
72
+ async function listSlugsIn(slugs, options = {}) {
73
+ const normalizedSlugs = [...new Set(
74
+ (Array.isArray(slugs) ? slugs : []).map((slug) => normalizeLowerText(slug)).filter(Boolean)
75
+ )];
76
+ if (normalizedSlugs.length < 1) {
77
+ return new Set();
78
+ }
79
+
80
+ const rows = await queryAllJsonRestCollectionRows(
81
+ api.resources.workspaces,
82
+ {
83
+ queryParams: {
84
+ filters: {
85
+ slugs: normalizedSlugs
86
+ }
87
+ },
88
+ transaction: resolveTransaction(options),
89
+ format: "plain"
90
+ },
91
+ options.context
92
+ );
93
+ return new Set(rows.map((row) => normalizeLowerText(row.slug)));
94
+ }
95
+
66
96
  async function findPersonalByOwnerUserId(userId, options = {}) {
67
97
  const normalizedUserId = normalizeRecordId(userId, { fallback: null });
68
98
  if (!normalizedUserId) {
@@ -231,6 +261,7 @@ function createRepository({ api }) {
231
261
  withTransaction,
232
262
  findById,
233
263
  findBySlug,
264
+ listSlugsIn,
234
265
  findPersonalByOwnerUserId,
235
266
  insert,
236
267
  updateById,
@@ -7,6 +7,8 @@ import { listCatalogRolePermissions } from "../../../shared/roles.js";
7
7
  import { TENANCY_MODE_NONE } from "../../../shared/tenancyProfile.js";
8
8
  import { mapWorkspaceSummary } from "../formatters/workspaceFormatter.js";
9
9
 
10
+ const WORKSPACE_SLUG_CANDIDATE_LIMIT = 1000;
11
+
10
12
  function toSlugPart(value) {
11
13
  const normalized = normalizeLowerText(value)
12
14
  .replace(/[^a-z0-9]+/g, "-")
@@ -69,14 +71,16 @@ function createService({
69
71
  const workspacePolicy = tenancyProfile.workspace;
70
72
 
71
73
  async function ensureUniqueWorkspaceSlug(baseSlug, options = {}) {
72
- let suffix = 0;
73
- while (suffix < 1000) {
74
- suffix += 1;
75
- const candidate = suffix === 1 ? toSlugPart(baseSlug) : `${toSlugPart(baseSlug)}-${suffix}`;
76
- const existing = await workspacesRepository.findBySlug(candidate, options);
77
- if (!existing) {
78
- return candidate;
79
- }
74
+ const slugPart = toSlugPart(baseSlug);
75
+ // The base, then base-2 to base-1000, read in one query however many are taken.
76
+ const candidates = Array.from(
77
+ { length: WORKSPACE_SLUG_CANDIDATE_LIMIT },
78
+ (_, index) => (index === 0 ? slugPart : `${slugPart}-${index + 1}`)
79
+ );
80
+ const takenSlugs = await workspacesRepository.listSlugsIn(candidates, options);
81
+ const candidate = candidates.find((slug) => !takenSlugs.has(slug));
82
+ if (candidate) {
83
+ return candidate;
80
84
  }
81
85
  throw new Error("Unable to generate unique workspace slug.");
82
86
  }
@@ -208,7 +212,40 @@ function createService({
208
212
  return returnJsonApiData(await workspacesRepository.updateById(workspaceContext.workspace.id, patch, options));
209
213
  }
210
214
 
211
- async function resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options = {}) {
215
+ // The checks that run before a request's handler (its auth policy and its action context)
216
+ // each need the request's workspace context. They pass the request and share one resolution
217
+ // for the same user, slug and membership requirement, instead of reading the workspace, the
218
+ // membership and the settings again for each check. A call in a transaction, or without a
219
+ // request, resolves on its own.
220
+ const requestWorkspaceContexts = new WeakMap();
221
+
222
+ /**
223
+ * @param {{ id?: unknown } | null} user
224
+ * @param {string} workspaceSlug
225
+ * @param {{ request?: object, requireMembership?: boolean, trx?: unknown }} [options]
226
+ */
227
+ function resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options = {}) {
228
+ const request = options.request;
229
+ if (!request || typeof request !== "object" || options.trx) {
230
+ return readWorkspaceContextForUserBySlug(user, workspaceSlug, options);
231
+ }
232
+ const key = [
233
+ normalizeRecordId(user?.id, { fallback: "" }),
234
+ normalizeLowerText(workspaceSlug),
235
+ options.requireMembership !== false
236
+ ].join("\0");
237
+ let resolutions = requestWorkspaceContexts.get(request);
238
+ if (!resolutions) {
239
+ resolutions = new Map();
240
+ requestWorkspaceContexts.set(request, resolutions);
241
+ }
242
+ if (!resolutions.has(key)) {
243
+ resolutions.set(key, readWorkspaceContextForUserBySlug(user, workspaceSlug, options));
244
+ }
245
+ return resolutions.get(key);
246
+ }
247
+
248
+ async function readWorkspaceContextForUserBySlug(user, workspaceSlug, options = {}) {
212
249
  const requireMembership = options.requireMembership !== false;
213
250
  const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
214
251
  if (!normalizedUserId) {
@@ -1,4 +1,5 @@
1
1
  import { normalizeLowerText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
2
3
  import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
3
4
 
4
5
  const workspaceMembershipsResource = defineCrudResource({
@@ -8,6 +9,12 @@ const workspaceMembershipsResource = defineCrudResource({
8
9
  id: { type: "id", actualField: "id" },
9
10
  workspace: { type: "id", actualField: "workspace_id", filterOperator: "=" },
10
11
  user: { type: "id", actualField: "user_id", filterOperator: "=" },
12
+ users: {
13
+ type: "array",
14
+ actualField: "user_id",
15
+ filterOperator: "in",
16
+ items: { type: "string", pattern: RECORD_ID_PATTERN }
17
+ },
11
18
  status: { type: "string", actualField: "status", filterOperator: "=" }
12
19
  },
13
20
  schema: {
@@ -77,6 +77,7 @@ const resource = defineCrudResource({
77
77
  searchSchema: {
78
78
  id: { type: "id", actualField: "id" },
79
79
  slug: { type: "string", actualField: "slug", filterOperator: "=" },
80
+ slugs: { type: "array", actualField: "slug", filterOperator: "in", items: { type: "string" } },
80
81
  owner: { type: "id", actualField: "owner_user_id", filterOperator: "=" },
81
82
  isPersonal: { type: "boolean", actualField: "is_personal", filterOperator: "=" }
82
83
  },
@@ -141,21 +141,24 @@ test("workspace realtime audiences are the actor's active memberships", async ()
141
141
  services: { directory: { async resolveWorkspaceContextForUserBySlug() { return null; } } },
142
142
  repositories: {
143
143
  workspaceMemberships: {
144
- async listActiveWorkspaceIdsByUserId(userId) {
145
- assert.equal(userId, "9");
146
- return ["11", "12"];
144
+ async listActiveWorkspaceIdsByUserIds(userIds) {
145
+ assert.deepEqual(userIds, ["9", "10"]);
146
+ return new Map([["9", ["11", "12"]], ["10", []]]);
147
147
  }
148
148
  }
149
149
  }
150
150
  }
151
151
  });
152
152
  assert.equal(audienceContributors.length, 1);
153
- assert.deepEqual(await audienceContributors[0].resolveActorScopes("9"), [
154
- { kind: "workspace", id: "11" },
155
- { kind: "workspace_user", workspaceId: "11", userId: "9" },
156
- { kind: "workspace", id: "12" },
157
- { kind: "workspace_user", workspaceId: "12", userId: "9" }
158
- ]);
153
+ assert.deepEqual(await audienceContributors[0].resolveActorsScopes(["9", "10"]), new Map([
154
+ ["9", [
155
+ { kind: "workspace", id: "11" },
156
+ { kind: "workspace_user", workspaceId: "11", userId: "9" },
157
+ { kind: "workspace", id: "12" },
158
+ { kind: "workspace_user", workspaceId: "12", userId: "9" }
159
+ ]],
160
+ ["10", []]
161
+ ]));
159
162
  });
160
163
 
161
164
  test("workspace settings color defaults must be hex colors", () => {
@@ -21,6 +21,8 @@ import { renderDefaultWorkspaceInviteEmail } from "../src/server/workspaceMember
21
21
  import { unconfiguredWorkspaceInviteMailer } from "../src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js";
22
22
  import { createWorkspaceInviteUrlBuilder } from "../src/server/workspaceMembers/workspaceInviteUrls.js";
23
23
  import { createService as createMembersService } from "../src/server/workspaceMembers/workspaceMembersService.js";
24
+ import { buildWorkspaceName, createService as createDirectoryService } from "../src/server/common/services/workspaceDirectoryService.js";
25
+ import { resolveTenancyProfile } from "../src/shared/tenancyProfile.js";
24
26
  import { createWorkspacesServerI18n } from "../src/server/i18n.js";
25
27
  import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
26
28
  import { verifyInvitationTransactions } from "./support/invitationTransactions.js";
@@ -84,6 +86,8 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
84
86
  let memberships;
85
87
  let invites;
86
88
  let settings;
89
+ let usersExtensions;
90
+ let profileProjector;
87
91
  let ownsTables = false;
88
92
 
89
93
  before(async () => {
@@ -109,11 +113,13 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
109
113
  await migration.up(knex);
110
114
  }
111
115
  api = await createJsonRestApiHost({ knex, logger: { error() {}, warn() {}, info() {}, debug() {} } });
112
- const { identity } = await UsersIdentityProvider.setup({
116
+ usersExtensions = createUsersExtensions();
117
+ const { identity, profileProjector: projector } = await UsersIdentityProvider.setup({
113
118
  authExtensions: createAuthExtensions(),
114
- extensions: createUsersExtensions(),
119
+ extensions: usersExtensions,
115
120
  jsonRestApi: api
116
121
  });
122
+ profileProjector = projector;
117
123
  ({ userProfiles: profiles, userSettings } = identity.repositories);
118
124
  await installWorkspaceResources(api);
119
125
  workspaces = createWorkspacesRepository({ api });
@@ -281,6 +287,19 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
281
287
  return (await knex("workspaces").orderBy("id").select("id")).map((row) => String(row.id));
282
288
  }
283
289
 
290
+ // The run's result and the SQL statements it issued.
291
+ async function statementsOf(run) {
292
+ let statements = 0;
293
+ const onQuery = () => { statements += 1; };
294
+ knex.on("query", onQuery);
295
+ try {
296
+ const result = await run();
297
+ return { result, statements };
298
+ } finally {
299
+ knex.removeListener("query", onQuery);
300
+ }
301
+ }
302
+
284
303
  test("a workspace's 1,050 active members are all listed, sorted by name, inside a transaction too", async () => {
285
304
  // Inserted in an order unrelated to their names, so the pages' id order is not the name order.
286
305
  const numbers = Array.from({ length: 1050 }, (_, index) => String((index * 7919) % 1050).padStart(4, "0"));
@@ -336,17 +355,6 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
336
355
  ...usernames.slice(10, 210).map((username) => membership(large.id, username, username === "person-209" ? "revoked" : "active"))
337
356
  ]);
338
357
  const membersService = createMembers();
339
- async function statementsOf(run) {
340
- let statements = 0;
341
- const onQuery = () => { statements += 1; };
342
- knex.on("query", onQuery);
343
- try {
344
- const result = await run();
345
- return { result, statements };
346
- } finally {
347
- knex.removeListener("query", onQuery);
348
- }
349
- }
350
358
 
351
359
  for (const [workspace, memberName, removedName] of [
352
360
  [small, "person-004", "person-009"],
@@ -377,6 +385,94 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
377
385
  });
378
386
  });
379
387
 
388
+ // `base`, then `base-2` to `base-<count>`: the names `count` earlier namesakes took.
389
+ const namesakes = (base, count) => [base, ...Array.from({ length: count - 1 }, (_, index) => `${base}-${index + 2}`)];
390
+
391
+ test("a new profile's username costs the same statements after 10 and after 200 namesakes", async () => {
392
+ await insertUsers([...namesakes("info", 10), ...namesakes("john", 200)]);
393
+ const signUp = (base) => statementsOf(() => profiles.upsert({
394
+ authProvider: "local",
395
+ authProviderUserSid: `newcomer-${base}`,
396
+ email: `${base}@newcomer.example`,
397
+ displayName: base
398
+ }));
399
+
400
+ const few = await signUp("info");
401
+ const many = await signUp("john");
402
+ assert.equal(few.result.username, "info-11");
403
+ assert.equal(many.result.username, "john-201");
404
+ // A read per taken candidate would add about 190 statements.
405
+ assert.equal(many.statements, few.statements, `${few.statements} statements after 10 namesakes, ${many.statements} after 200`);
406
+ });
407
+
408
+ test("a new workspace's slug costs the same statements after 10 and after 200 namesakes", async () => {
409
+ const users = await insertUsers(["founder"]);
410
+ const ownerUserId = users.get("founder");
411
+ await insertRows("workspaces", [...namesakes("acme", 10), ...namesakes("globex", 200)].map((slug) => ({
412
+ slug, name: slug, owner_user_id: ownerUserId, is_personal: false
413
+ })));
414
+ const directory = createDirectoryService({
415
+ tenancyProfile: resolveTenancyProfile({ tenancyMode: "workspaces", tenancyPolicy: { workspace: { allowSelfCreate: true } } }),
416
+ roleCatalog: createWorkspaceRoleCatalog(),
417
+ personalWorkspaceName: buildWorkspaceName,
418
+ workspacesRepository: workspaces,
419
+ workspaceMembershipsRepository: memberships,
420
+ workspaceSettingsRepository: settings
421
+ });
422
+ const create = (name) => statementsOf(() => directory.createWorkspaceForAuthenticatedUser({ id: ownerUserId }, { name }));
423
+
424
+ const few = await create("Acme");
425
+ const many = await create("Globex");
426
+ assert.equal(few.result.value.slug, "acme-11");
427
+ assert.equal(many.result.value.slug, "globex-201");
428
+ // A read per taken candidate would add about 190 statements.
429
+ assert.equal(many.statements, few.statements, `${few.statements} statements after 10 namesakes, ${many.statements} after 200`);
430
+ });
431
+
432
+ test("an ordinary request reads its matching profile in one statement; sign-in and a changed identity synchronize in full", async () => {
433
+ const directory = createDirectoryService({
434
+ tenancyProfile: resolveTenancyProfile({ tenancyMode: "personal" }),
435
+ roleCatalog: createWorkspaceRoleCatalog(),
436
+ personalWorkspaceName: buildWorkspaceName,
437
+ workspacesRepository: workspaces,
438
+ workspaceMembershipsRepository: memberships,
439
+ workspaceSettingsRepository: settings
440
+ });
441
+ // What WorkspacesIntegrationsProvider registers: a synchronization provisions the personal workspace.
442
+ usersExtensions.registerProfileSyncLifecycleContributor({
443
+ contributorId: "workspaces.provisioning",
444
+ order: 100,
445
+ afterIdentityProfileSynced: ({ profile, options }) => directory.ensureProvisionedWorkspaceForAuthenticatedUser(profile, options)
446
+ });
447
+ const identity = (displayName) => ({ authProvider: "local", authProviderUserSid: "identity-ada", email: "ada@example.com", displayName });
448
+ const personalWorkspaces = (userId) => knex("workspaces").where({ owner_user_id: userId, is_personal: true }).count({ count: "*" })
449
+ .first().then((row) => Number(row.count));
450
+
451
+ const signIn = await statementsOf(() => profileProjector.syncIdentityProfile(identity("Ada")));
452
+ const userId = signIn.result.id;
453
+ assert.equal(await personalWorkspaces(userId), 1);
454
+ const read = await statementsOf(() => profiles.findByIdentity({ provider: "local", providerUserId: "identity-ada" }));
455
+
456
+ const request = await statementsOf(() => profileProjector.syncIdentityProfile(identity("Ada"), { onlyWhenChanged: true }));
457
+ assert.equal(request.result.id, userId);
458
+ assert.equal(request.statements, read.statements, "An ordinary request reads the profile and nothing else");
459
+ assert.ok(signIn.statements > read.statements, `sign-in ${signIn.statements} statements, a profile read ${read.statements}`);
460
+
461
+ // A deleted personal workspace is not provisioned again by an ordinary request, only by the next full synchronization.
462
+ await knex("workspace_settings").delete();
463
+ await knex("workspace_memberships").delete();
464
+ await knex("workspaces").delete();
465
+ await profileProjector.syncIdentityProfile(identity("Ada"), { onlyWhenChanged: true });
466
+ assert.equal(await personalWorkspaces(userId), 0);
467
+ await profileProjector.syncIdentityProfile(identity("Ada"));
468
+ assert.equal(await personalWorkspaces(userId), 1);
469
+
470
+ // An identity whose name changed outside the app is synchronized by the request that carries it.
471
+ const renamed = await statementsOf(() => profileProjector.syncIdentityProfile(identity("Ada Lovelace"), { onlyWhenChanged: true }));
472
+ assert.equal(renamed.result.displayName, "Ada Lovelace");
473
+ assert.ok(renamed.statements > read.statements);
474
+ });
475
+
380
476
  test("a member of 130 workspaces has all of them listed", async () => {
381
477
  const users = await insertUsers(["host", "roaming"]);
382
478
  const workspaceIds = await insertWorkspaces(135, users.get("host"));
@@ -393,6 +489,43 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
393
489
  assert.deepEqual((await workspaces.listForUserId(users.get("roaming"))).map(({ id }) => id), activeIds);
394
490
  });
395
491
 
492
+ test("the bulk read lists each user's active workspaces at the same cost for 10 and 200 users", async () => {
493
+ const usernames = Array.from({ length: 202 }, (_, index) => `person-${String(index).padStart(3, "0")}`);
494
+ const users = await insertUsers(usernames);
495
+ const [first, second, third] = await insertWorkspaces(3, users.get("person-000"));
496
+ // Each person is active in the first workspace; even persons are active in the second
497
+ // too, odd ones were invited to it, and every tenth was removed from the third.
498
+ await insertRows("workspace_memberships", usernames.flatMap((username, index) => [
499
+ { workspace_id: first, user_id: users.get(username), role_sid: "member", status: "active" },
500
+ {
501
+ workspace_id: second,
502
+ user_id: users.get(username),
503
+ role_sid: "member",
504
+ status: index % 2 === 0 ? "active" : "invited"
505
+ },
506
+ ...(index % 10 === 0
507
+ ? [{ workspace_id: third, user_id: users.get(username), role_sid: "member", status: "removed" }]
508
+ : [])
509
+ ]));
510
+ const expectedFor = (index) => (index % 2 === 0 ? [first, second] : [first]);
511
+ const idsOf = (names) => names.map((username) => users.get(username));
512
+
513
+ const small = await statementsOf(() => memberships.listActiveWorkspaceIdsByUserIds(idsOf(usernames.slice(0, 10))));
514
+ const large = await statementsOf(() => memberships.listActiveWorkspaceIdsByUserIds(idsOf(usernames.slice(0, 200))));
515
+ for (const [read, names] of [[small, usernames.slice(0, 10)], [large, usernames.slice(0, 200)]]) {
516
+ assert.deepEqual([...read.result.keys()].sort(), idsOf(names).sort());
517
+ names.forEach((username, index) => {
518
+ assert.deepEqual([...read.result.get(users.get(username))].sort(), expectedFor(index).sort());
519
+ });
520
+ }
521
+ assert.equal(large.statements, small.statements);
522
+
523
+ // A user with no active membership is still answered, with no workspaces.
524
+ const outsider = (await insertUsers(["outsider"])).get("outsider");
525
+ assert.deepEqual(await memberships.listActiveWorkspaceIdsByUserIds([outsider]), new Map([[outsider, []]]));
526
+ assert.deepEqual(await memberships.listActiveWorkspaceIdsByUserIds([]), new Map());
527
+ });
528
+
396
529
  test("a workspace's 120 pending invitations and an email's 110 are all listed", async () => {
397
530
  const users = await insertUsers(["host"]);
398
531
  const [crowdedId, ...otherIds] = await insertWorkspaces(111, users.get("host"));
@@ -57,13 +57,17 @@ test("workspaces-core resources expose messages for all operations", () => {
57
57
  });
58
58
 
59
59
  test("workspaces-core repository filters are declared on canonical resources", () => {
60
- assert.deepEqual(Object.keys(workspaceResource.searchSchema), ["id", "slug", "owner", "isPersonal"]);
60
+ assert.deepEqual(Object.keys(workspaceResource.searchSchema), ["id", "slug", "slugs", "owner", "isPersonal"]);
61
61
  assert.equal(workspaceResource.searchSchema.slug.actualField, "slug");
62
+ assert.equal(workspaceResource.searchSchema.slugs.actualField, "slug");
63
+ assert.equal(workspaceResource.searchSchema.slugs.filterOperator, "in");
62
64
  assert.equal(workspaceResource.searchSchema.owner.actualField, "owner_user_id");
63
65
 
64
- assert.deepEqual(Object.keys(workspaceMembershipsResource.searchSchema), ["id", "workspace", "user", "status"]);
66
+ assert.deepEqual(Object.keys(workspaceMembershipsResource.searchSchema), ["id", "workspace", "user", "users", "status"]);
65
67
  assert.equal(workspaceMembershipsResource.searchSchema.workspace.actualField, "workspace_id");
66
68
  assert.equal(workspaceMembershipsResource.searchSchema.user.actualField, "user_id");
69
+ assert.equal(workspaceMembershipsResource.searchSchema.users.actualField, "user_id");
70
+ assert.equal(workspaceMembershipsResource.searchSchema.users.filterOperator, "in");
67
71
 
68
72
  assert.deepEqual(Object.keys(workspaceInvitesResource.searchSchema), ["id", "workspace", "email", "status", "tokenHash"]);
69
73
  assert.equal(workspaceInvitesResource.searchSchema.workspace.actualField, "workspace_id");
@@ -75,7 +75,8 @@ test("workspace auth policy context resolver resolves workspace context from use
75
75
  actor,
76
76
  workspaceSlug: "acme",
77
77
  options: {
78
- requireMembership: true
78
+ requireMembership: true,
79
+ request
79
80
  }
80
81
  }
81
82
  ]);
@@ -287,3 +287,43 @@ test("workspaceMembershipsRepository.listActiveWorkspaceIdsByUserId returns norm
287
287
  }
288
288
  ]);
289
289
  });
290
+
291
+ test("workspaceMembershipsRepository.listActiveWorkspaceIdsByUserIds reads 1,000 users per query without including workspaces", async () => {
292
+ const queryCalls = [];
293
+ const api = {
294
+ resources: {
295
+ workspaceMemberships: {
296
+ vars: { queryMaxLimit: 100 },
297
+ async query({ queryParams, format }) {
298
+ assert.equal(format, "plain");
299
+ queryCalls.push(queryParams);
300
+ // Every asked user is active in workspace 7, and user 1 in workspace 8 too.
301
+ const rows = queryParams.filters.users.flatMap((userId) => [
302
+ { id: `m${userId}`, workspace: { id: "7" }, user: { id: userId }, status: "active" },
303
+ ...(userId === "1" ? [{ id: "m1-8", workspace: { id: "8" }, user: { id: "1" }, status: "active" }] : [])
304
+ ]);
305
+ return asCollectionDocument(rows);
306
+ }
307
+ }
308
+ }
309
+ };
310
+ const repository = createRepository({ api });
311
+ const userIds = Array.from({ length: 1001 }, (_, index) => String(index + 1));
312
+
313
+ const workspaceIds = await repository.listActiveWorkspaceIdsByUserIds([...userIds, "1", "", null]);
314
+
315
+ assert.equal(workspaceIds.size, 1001);
316
+ assert.deepEqual(workspaceIds.get("1"), ["7", "8"]);
317
+ assert.deepEqual(workspaceIds.get("1001"), ["7"]);
318
+ assert.deepEqual(queryCalls.map(({ filters }) => filters.users.length), [1000, 1]);
319
+ assert.deepEqual(queryCalls[1], {
320
+ filters: {
321
+ users: ["1001"],
322
+ status: "active"
323
+ },
324
+ sort: ["id"],
325
+ page: { size: 1000 }
326
+ });
327
+ assert.deepEqual(await repository.listActiveWorkspaceIdsByUserIds([]), new Map());
328
+ assert.equal(queryCalls.length, 2, "No users need no query");
329
+ });
@@ -1,5 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { createWorkspaceActionContextContributor } from "../src/server/common/contributors/workspaceActionContextContributor.js";
4
+ import { createWorkspaceAuthPolicyContextResolver } from "../src/server/common/contributors/workspaceAuthPolicyContextResolver.js";
3
5
  import { buildWorkspaceName, createService } from "../src/server/common/services/workspaceDirectoryService.js";
4
6
  import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
5
7
  import { resolveTenancyProfile } from "../src/shared/tenancyProfile.js";
@@ -49,6 +51,8 @@ function createWorkspaceServiceFixture({
49
51
  }
50
52
  } = {}) {
51
53
  const calls = {
54
+ findBySlug: 0,
55
+ listSlugsIn: 0,
52
56
  findPersonalByOwnerUserId: 0,
53
57
  listForUserId: 0,
54
58
  insert: 0,
@@ -89,6 +93,7 @@ function createWorkspaceServiceFixture({
89
93
  }),
90
94
  workspacesRepository: {
91
95
  async findBySlug(slug) {
96
+ calls.findBySlug += 1;
92
97
  const normalizedSlug = String(slug || "").trim().toLowerCase();
93
98
  const workspace = workspaceBySlug.get(normalizedSlug);
94
99
  if (!workspace) {
@@ -96,6 +101,10 @@ function createWorkspaceServiceFixture({
96
101
  }
97
102
  return { ...workspace };
98
103
  },
104
+ async listSlugsIn(slugs) {
105
+ calls.listSlugsIn += 1;
106
+ return new Set(slugs.filter((slug) => workspaceBySlug.has(String(slug).trim().toLowerCase())));
107
+ },
99
108
  async findPersonalByOwnerUserId() {
100
109
  calls.findPersonalByOwnerUserId += 1;
101
110
  return personalWorkspaceState ? { ...personalWorkspaceState } : null;
@@ -412,6 +421,42 @@ test("workspaceService.createWorkspaceForAuthenticatedUser generates an omitted
412
421
  assert.equal(insertedPayloads[0].ownerUserId, "7");
413
422
  });
414
423
 
424
+ // The slug a new workspace named `name` gets while `takenSlugs` are held, and how many reads chose it.
425
+ async function chooseWorkspaceSlug(name, takenSlugs) {
426
+ const { service, calls } = createWorkspaceServiceFixture({
427
+ tenancyMode: "workspaces",
428
+ tenancyPolicy: { workspace: { allowSelfCreate: true } },
429
+ personalWorkspace: null,
430
+ additionalWorkspaces: takenSlugs.map((slug, index) => ({
431
+ id: String(1000 + index), slug, name: slug, ownerUserId: "8", isPersonal: false, avatarUrl: ""
432
+ }))
433
+ });
434
+ const workspace = (await service.createWorkspaceForAuthenticatedUser({ id: "7" }, { name })).value;
435
+ assert.equal(calls.listSlugsIn, 1);
436
+ return workspace.slug;
437
+ }
438
+
439
+ test("workspaceService chooses the first free slug of base, base-2 to base-1000 in one read", async () => {
440
+ assert.equal(await chooseWorkspaceSlug("Acme", []), "acme");
441
+ assert.equal(await chooseWorkspaceSlug("Acme", ["acme"]), "acme-2");
442
+ assert.equal(
443
+ await chooseWorkspaceSlug("Acme", ["acme", "acme-2", "acme-4", "acme-5"]),
444
+ "acme-3"
445
+ );
446
+ assert.equal(await chooseWorkspaceSlug("Acme", ["acme-2"]), "acme");
447
+
448
+ const longName = "a".repeat(60);
449
+ const trimmedBase = "a".repeat(48);
450
+ assert.equal(await chooseWorkspaceSlug(longName, []), trimmedBase);
451
+ assert.equal(await chooseWorkspaceSlug(longName, [trimmedBase]), `${trimmedBase}-2`);
452
+ });
453
+
454
+ test("workspaceService fails the same way when base to base-1000 are all taken", async () => {
455
+ const taken = ["acme", ...Array.from({ length: 999 }, (_, index) => `acme-${index + 2}`)];
456
+ await assert.rejects(chooseWorkspaceSlug("Acme", taken), /Unable to generate unique workspace slug\./);
457
+ assert.equal(await chooseWorkspaceSlug("Acme", taken.slice(0, -1)), "acme-1000");
458
+ });
459
+
415
460
  test("workspaceService.createWorkspaceForAuthenticatedUser rejects creation when self-create policy is disabled", async () => {
416
461
  const { service } = createWorkspaceServiceFixture({
417
462
  tenancyMode: "workspaces"
@@ -650,6 +695,48 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug resolves permissions
650
695
  assert.deepEqual(context.permissions, ["workspace.settings.update"]);
651
696
  });
652
697
 
698
+ test("one request's auth policy and action context share one workspace context resolution", async () => {
699
+ const { service, calls } = createWorkspaceServiceFixture({
700
+ additionalWorkspaces: [{ id: "42", slug: "team-alpha", name: "Team Alpha", ownerUserId: "99", isPersonal: false, avatarUrl: "" }]
701
+ });
702
+ const resolvePolicy = createWorkspaceAuthPolicyContextResolver({ workspaceService: service, workspaceMembershipOptionalSurfaceIds: [] });
703
+ const contributor = createWorkspaceActionContextContributor({
704
+ workspaceService: service,
705
+ workspaceMembershipOptionalSurfaceIds: [],
706
+ workspaceSurfaceIds: ["admin"]
707
+ });
708
+ const user = { id: "7", email: "chiara@example.com", displayName: "Chiara" };
709
+ const handle = async () => {
710
+ const request = { user, params: { workspaceSlug: "team-alpha" } };
711
+ const policy = await resolvePolicy({ request, actor: user, meta: { permission: "workspace.settings.update" } });
712
+ const action = await contributor.contribute({
713
+ definition: { id: "workspace.settings.update", surfaces: ["admin"] },
714
+ input: { workspaceSlug: "Team-Alpha" },
715
+ context: { requestMeta: { request } }
716
+ });
717
+ return { policy, action };
718
+ };
719
+
720
+ const { policy, action } = await handle();
721
+ assert.equal(calls.findBySlug, 1, "The action context reuses the auth policy's resolution");
722
+ assert.equal(calls.ensureWorkspaceSettings, 1);
723
+ assert.equal(action.requestMeta.resolvedWorkspaceContext.workspace, policy.workspace);
724
+ assert.deepEqual(action.requestMeta.resolvedWorkspaceContext.workspaceSettings, { invitesEnabled: true });
725
+ assert.deepEqual(action.permissions, policy.permissions);
726
+
727
+ await handle();
728
+ assert.equal(calls.findBySlug, 2, "The next request resolves again");
729
+ await service.resolveWorkspaceContextForUserBySlug(user, "team-alpha");
730
+ await service.resolveWorkspaceContextForUserBySlug(user, "team-alpha");
731
+ assert.equal(calls.findBySlug, 4, "A call without a request resolves each time");
732
+ const request = {};
733
+ await service.resolveWorkspaceContextForUserBySlug(user, "team-alpha", { request });
734
+ await service.resolveWorkspaceContextForUserBySlug(user, "team-alpha", { request, requireMembership: false });
735
+ await service.resolveWorkspaceContextForUserBySlug({ ...user, id: "8" }, "team-alpha", { request });
736
+ await service.resolveWorkspaceContextForUserBySlug(user, "team-alpha", { request, trx: {} });
737
+ assert.equal(calls.findBySlug, 8, "Another membership requirement, user or a transaction resolves on its own");
738
+ });
739
+
653
740
  test("workspaceService.getWorkspaceForAuthenticatedUser resolves workspace from slug context", async () => {
654
741
  const { service } = createWorkspaceServiceFixture({
655
742
  additionalWorkspaces: [
@@ -43,6 +43,7 @@ function createWorkspacesApiStub({
43
43
  insertError = null
44
44
  } = {}) {
45
45
  const state = {
46
+ queries: [],
46
47
  postPayload: null,
47
48
  patchPayload: null
48
49
  };
@@ -50,9 +51,16 @@ function createWorkspacesApiStub({
50
51
  const api = {
51
52
  resources: {
52
53
  workspaces: {
53
- async query({ queryParams, format }) {
54
+ vars: { queryMaxLimit: 100 },
55
+ async query({ queryParams, transaction, format }) {
54
56
  assert.equal(format, "plain");
55
57
  const filters = queryParams?.filters || {};
58
+ state.queries.push({ filters, transaction });
59
+
60
+ if (Object.hasOwn(filters, "slugs")) {
61
+ const rows = filters.slugs.map((slug) => rowsBySlug.get(String(slug))).filter(Boolean);
62
+ return asCollectionDocument(rows.map((row) => toWorkspaceRow(row)));
63
+ }
56
64
 
57
65
  if (Object.hasOwn(filters, "id")) {
58
66
  const row = rowsById.get(String(filters.id)) || null;
@@ -179,6 +187,26 @@ test("workspacesRepository.findById reads a canonical workspace row through json
179
187
  });
180
188
  });
181
189
 
190
+ test("workspacesRepository.listSlugsIn reads which slugs are taken in one query through the caller's transaction", async () => {
191
+ const trx = { trxId: "trx-slugs" };
192
+ const { api, state } = createWorkspacesApiStub({
193
+ rowsBySlug: new Map([
194
+ ["acme", { id: "7", slug: "acme", name: "Acme", ownerUserId: "9", isPersonal: false, avatarUrl: "" }],
195
+ ["acme-3", { id: "8", slug: "acme-3", name: "Acme 3", ownerUserId: "9", isPersonal: false, avatarUrl: "" }]
196
+ ])
197
+ });
198
+ const repository = createRepository({ api });
199
+
200
+ const taken = await repository.listSlugsIn([" ACME ", "acme-2", "acme-3", "acme"], { trx });
201
+
202
+ assert.deepEqual([...taken].sort(), ["acme", "acme-3"]);
203
+ assert.equal(state.queries.length, 1);
204
+ assert.deepEqual(state.queries[0].filters, { slugs: ["acme", "acme-2", "acme-3"] });
205
+ assert.equal(state.queries[0].transaction, trx);
206
+ assert.deepEqual([...await repository.listSlugsIn([])], []);
207
+ assert.equal(state.queries.length, 1);
208
+ });
209
+
182
210
  test("workspacesRepository.findPersonalByOwnerUserId returns the first personal workspace by canonical id order", async () => {
183
211
  const { api } = createWorkspacesApiStub({
184
212
  personalRowsByOwnerId: new Map([