@jskit-ai/workspaces-core 0.2.12 → 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,10 @@
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.
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.
5
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.
6
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.
7
11
  - The invitation email writes its expiry date with `Intl` in the recipient's locale and saved time zone (UTC without one), for example "November 2, 2026 at 12:00 AM UTC", instead of the raw ISO timestamp. An app's own `workspaces.invite-email-template` also receives the recipient's `timeZone` next to `locale`.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "test": "node --test --test-concurrency=1"
6
+ "test": "jskit test"
7
7
  },
8
8
  "exports": {
9
9
  "./server/WorkspacesFeature": "./src/server/WorkspacesFeature.js",
@@ -22,11 +22,11 @@
22
22
  "./locales/en.json": "./locales/en.json"
23
23
  },
24
24
  "dependencies": {
25
- "@jskit-ai/database-runtime": "0.1.259",
26
- "@jskit-ai/json-rest-api-core": "0.2.11",
27
- "@jskit-ai/resource-core": "0.1.201",
28
- "@jskit-ai/resource-crud-core": "0.1.201",
29
- "@jskit-ai/users-core": "0.2.12",
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.10",
202
- "@jskit-ai/http-runtime": "0.2.10",
203
- "@jskit-ai/kernel": "0.2.10"
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;
@@ -42,6 +45,21 @@ function normalizeMemberSummaryRow(row) {
42
45
  };
43
46
  }
44
47
 
48
+ // A membership read with its user included, as a member summary.
49
+ function memberSummaryFromMembership(row) {
50
+ if (!row) {
51
+ return null;
52
+ }
53
+
54
+ return normalizeMemberSummaryRow({
55
+ user_id: row.user?.id,
56
+ role_sid: row.roleSid,
57
+ status: row.status,
58
+ display_name: row.user?.displayName,
59
+ email: row.user?.email
60
+ });
61
+ }
62
+
45
63
  /** @param {{ api: import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi }} dependencies */
46
64
  function createRepository({ api }) {
47
65
  const withTransaction = (work) => retryDeadlocks(() => api.transaction(work));
@@ -215,20 +233,34 @@ function createRepository({ api }) {
215
233
  { include: ["user"] }
216
234
  );
217
235
 
218
- const members = rows
219
- .map((row) => normalizeMemberSummaryRow({
220
- user_id: row?.user?.id,
221
- role_sid: row?.roleSid,
222
- status: row?.status,
223
- display_name: row?.user?.displayName,
224
- email: row?.user?.email
225
- }))
226
- .filter(Boolean);
236
+ const members = rows.map(memberSummaryFromMembership).filter(Boolean);
227
237
 
228
238
  members.sort((left, right) => String(left.displayName || "").localeCompare(String(right.displayName || "")));
229
239
  return members;
230
240
  }
231
241
 
242
+ // One active member's summary, as listActiveByWorkspaceId lists it, or null. Its cost does not grow
243
+ // with the workspace's members.
244
+ async function findActiveByWorkspaceIdAndUserId(workspaceId, userId, options = {}) {
245
+ const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
246
+ const normalizedUserId = normalizeRecordId(userId, { fallback: null });
247
+ if (!normalizedWorkspaceId || !normalizedUserId) {
248
+ return null;
249
+ }
250
+
251
+ const rows = await queryMemberships(
252
+ {
253
+ workspace: normalizedWorkspaceId,
254
+ user: normalizedUserId,
255
+ status: "active"
256
+ },
257
+ options,
258
+ { include: ["user"] }
259
+ );
260
+
261
+ return memberSummaryFromMembership(rows[0] || null);
262
+ }
263
+
232
264
  async function listActiveWorkspaceIdsByUserId(userId, options = {}) {
233
265
  const normalizedUserId = normalizeRecordId(userId, { fallback: null });
234
266
  if (!normalizedUserId) {
@@ -249,13 +281,48 @@ function createRepository({ api }) {
249
281
  .filter(Boolean);
250
282
  }
251
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
+
252
317
  return Object.freeze({
253
318
  withTransaction,
254
319
  findByWorkspaceIdAndUserId,
255
320
  ensureOwnerMembership,
256
321
  upsertMembership,
257
322
  listActiveByWorkspaceId,
258
- listActiveWorkspaceIdsByUserId
323
+ findActiveByWorkspaceIdAndUserId,
324
+ listActiveWorkspaceIdsByUserId,
325
+ listActiveWorkspaceIdsByUserIds
259
326
  });
260
327
  }
261
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) {
@@ -208,6 +208,13 @@ function createService({
208
208
  });
209
209
  }
210
210
 
211
+ // One active member's summary, the same as their listMembers entry, or null for a removed or
212
+ // unknown member. It reads one membership, so a per-member lookup costs the same in any workspace.
213
+ async function getMember(workspace, userId, options = {}) {
214
+ const member = await workspaceMembershipsRepository.findActiveByWorkspaceIdAndUserId(workspace.id, userId, options);
215
+ return member ? mapMemberSummary(member, workspace) : null;
216
+ }
217
+
211
218
  async function listMembersResponse(workspace, options = {}) {
212
219
  return returnJsonApiData(await listMembers(workspace, options));
213
220
  }
@@ -410,6 +417,7 @@ function createService({
410
417
  listRoles,
411
418
  listRolesResponse,
412
419
  listMembers,
420
+ getMember,
413
421
  listMembersResponse,
414
422
  updateMemberRole,
415
423
  removeMember,
@@ -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 });
@@ -238,6 +244,20 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
238
244
  assert.deepEqual(await workspaces.listForUserId(member.id), []);
239
245
  });
240
246
 
247
+ const createMembers = () => createMembersService({
248
+ workspaceMembershipsRepository: memberships,
249
+ workspaceInvitesRepository: invites,
250
+ inviteExpiresInMs: 86400000,
251
+ roleCatalog: createWorkspaceRoleCatalog(),
252
+ workspaceInvitationsEnabled: false,
253
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
254
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
255
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail,
256
+ i18n: createWorkspacesServerI18n(),
257
+ userProfilesRepository: profiles,
258
+ userSettingsRepository: userSettings
259
+ });
260
+
241
261
  // Lists of more rows than json-rest-api's default page (20) and maximum page (100), written in bulk.
242
262
  async function insertRows(table, rows) {
243
263
  for (let start = 0; start < rows.length; start += 100) {
@@ -267,6 +287,19 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
267
287
  return (await knex("workspaces").orderBy("id").select("id")).map((row) => String(row.id));
268
288
  }
269
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
+
270
303
  test("a workspace's 1,050 active members are all listed, sorted by name, inside a transaction too", async () => {
271
304
  // Inserted in an order unrelated to their names, so the pages' id order is not the name order.
272
305
  const numbers = Array.from({ length: 1050 }, (_, index) => String((index * 7919) % 1050).padStart(4, "0"));
@@ -289,19 +322,7 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
289
322
  assert.deepEqual(listed.map(({ displayName }) => displayName), expectedNames);
290
323
  assert.equal(new Set(listed.map(({ userId }) => userId)).size, 1050);
291
324
 
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
- });
325
+ const membersService = createMembers();
305
326
  const { members } = await membersService.listMembers(workspace);
306
327
  assert.deepEqual(members.map(({ userId }) => userId), listed.map(({ userId }) => userId));
307
328
  assert.deepEqual(members.filter(({ isOwner }) => isOwner).map(({ userId }) => userId), [ownerId]);
@@ -315,6 +336,143 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
315
336
  });
316
337
  });
317
338
 
339
+ test("getMember answers a member's listMembers entry at the same cost among 10 and 200 members", async () => {
340
+ const usernames = Array.from({ length: 212 }, (_, index) => `person-${String(index).padStart(3, "0")}`);
341
+ const users = await insertUsers(usernames, (username) => username.replace("person-", "Person "));
342
+ const ownerId = users.get("person-000");
343
+ const small = await workspaces.insert({ slug: "small-team", name: "Small Team", ownerUserId: ownerId });
344
+ const large = await workspaces.insert({ slug: "large-team", name: "Large Team", ownerUserId: ownerId });
345
+ // Small: persons 0-9 (9 removed). Large: persons 0 and 10-209 (209 removed). 210 and 211 are in neither.
346
+ const membership = (workspaceId, username, status = "active") => ({
347
+ workspace_id: workspaceId,
348
+ user_id: users.get(username),
349
+ role_sid: users.get(username) === ownerId ? "owner" : "member",
350
+ status
351
+ });
352
+ await insertRows("workspace_memberships", [
353
+ ...usernames.slice(0, 10).map((username) => membership(small.id, username, username === "person-009" ? "revoked" : "active")),
354
+ membership(large.id, "person-000"),
355
+ ...usernames.slice(10, 210).map((username) => membership(large.id, username, username === "person-209" ? "revoked" : "active"))
356
+ ]);
357
+ const membersService = createMembers();
358
+
359
+ for (const [workspace, memberName, removedName] of [
360
+ [small, "person-004", "person-009"],
361
+ [large, "person-150", "person-209"]
362
+ ]) {
363
+ const { members } = await membersService.listMembers(workspace);
364
+ for (const userId of [users.get(memberName), ownerId]) {
365
+ const entry = members.find((member) => member.userId === userId);
366
+ assert.ok(entry);
367
+ assert.deepEqual(await membersService.getMember(workspace, userId), entry);
368
+ }
369
+ assert.equal((await membersService.getMember(workspace, ownerId)).isOwner, true);
370
+ assert.equal(await membersService.getMember(workspace, users.get(removedName)), null);
371
+ assert.equal(await membersService.getMember(workspace, users.get("person-211")), null);
372
+ assert.equal(await membersService.getMember(workspace, "999999"), null);
373
+ }
374
+ assert.equal(await membersService.getMember(small, users.get("person-150")), null);
375
+
376
+ const smallRead = await statementsOf(() => membersService.getMember(small, users.get("person-004")));
377
+ const largeRead = await statementsOf(() => membersService.getMember(large, users.get("person-150")));
378
+ assert.equal(smallRead.result.displayName, "Person 004");
379
+ assert.equal(largeRead.result.displayName, "Person 150");
380
+ assert.equal(largeRead.statements, smallRead.statements);
381
+ assert.ok(smallRead.statements <= 4, `one member read costs ${smallRead.statements} statements`);
382
+ await profiles.withTransaction(async (trx) => {
383
+ const member = await membersService.getMember(large, users.get("person-150"), { trx });
384
+ assert.equal(member.userId, users.get("person-150"));
385
+ });
386
+ });
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
+
318
476
  test("a member of 130 workspaces has all of them listed", async () => {
319
477
  const users = await insertUsers(["host", "roaming"]);
320
478
  const workspaceIds = await insertWorkspaces(135, users.get("host"));
@@ -331,6 +489,43 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
331
489
  assert.deepEqual((await workspaces.listForUserId(users.get("roaming"))).map(({ id }) => id), activeIds);
332
490
  });
333
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
+
334
529
  test("a workspace's 120 pending invitations and an email's 110 are all listed", async () => {
335
530
  const users = await insertUsers(["host"]);
336
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
  ]);
@@ -283,7 +283,47 @@ test("workspaceMembershipsRepository.listActiveWorkspaceIdsByUserId returns norm
283
283
  },
284
284
  include: ["workspace"],
285
285
  sort: ["id"],
286
- page: { size: 100 }
286
+ page: { size: 1000 }
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([