@jskit-ai/workspaces-core 0.2.12 → 0.2.13

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,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `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
6
  - 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
7
  - 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
8
  - 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.13",
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.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",
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.11",
202
+ "@jskit-ai/http-runtime": "0.2.11",
203
+ "@jskit-ai/kernel": "0.2.11"
204
204
  }
205
205
  }
@@ -42,6 +42,21 @@ function normalizeMemberSummaryRow(row) {
42
42
  };
43
43
  }
44
44
 
45
+ // A membership read with its user included, as a member summary.
46
+ function memberSummaryFromMembership(row) {
47
+ if (!row) {
48
+ return null;
49
+ }
50
+
51
+ return normalizeMemberSummaryRow({
52
+ user_id: row.user?.id,
53
+ role_sid: row.roleSid,
54
+ status: row.status,
55
+ display_name: row.user?.displayName,
56
+ email: row.user?.email
57
+ });
58
+ }
59
+
45
60
  /** @param {{ api: import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi }} dependencies */
46
61
  function createRepository({ api }) {
47
62
  const withTransaction = (work) => retryDeadlocks(() => api.transaction(work));
@@ -215,20 +230,34 @@ function createRepository({ api }) {
215
230
  { include: ["user"] }
216
231
  );
217
232
 
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);
233
+ const members = rows.map(memberSummaryFromMembership).filter(Boolean);
227
234
 
228
235
  members.sort((left, right) => String(left.displayName || "").localeCompare(String(right.displayName || "")));
229
236
  return members;
230
237
  }
231
238
 
239
+ // One active member's summary, as listActiveByWorkspaceId lists it, or null. Its cost does not grow
240
+ // with the workspace's members.
241
+ async function findActiveByWorkspaceIdAndUserId(workspaceId, userId, options = {}) {
242
+ const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
243
+ const normalizedUserId = normalizeRecordId(userId, { fallback: null });
244
+ if (!normalizedWorkspaceId || !normalizedUserId) {
245
+ return null;
246
+ }
247
+
248
+ const rows = await queryMemberships(
249
+ {
250
+ workspace: normalizedWorkspaceId,
251
+ user: normalizedUserId,
252
+ status: "active"
253
+ },
254
+ options,
255
+ { include: ["user"] }
256
+ );
257
+
258
+ return memberSummaryFromMembership(rows[0] || null);
259
+ }
260
+
232
261
  async function listActiveWorkspaceIdsByUserId(userId, options = {}) {
233
262
  const normalizedUserId = normalizeRecordId(userId, { fallback: null });
234
263
  if (!normalizedUserId) {
@@ -255,6 +284,7 @@ function createRepository({ api }) {
255
284
  ensureOwnerMembership,
256
285
  upsertMembership,
257
286
  listActiveByWorkspaceId,
287
+ findActiveByWorkspaceIdAndUserId,
258
288
  listActiveWorkspaceIdsByUserId
259
289
  });
260
290
  }
@@ -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,
@@ -238,6 +238,20 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
238
238
  assert.deepEqual(await workspaces.listForUserId(member.id), []);
239
239
  });
240
240
 
241
+ const createMembers = () => createMembersService({
242
+ workspaceMembershipsRepository: memberships,
243
+ workspaceInvitesRepository: invites,
244
+ inviteExpiresInMs: 86400000,
245
+ roleCatalog: createWorkspaceRoleCatalog(),
246
+ workspaceInvitationsEnabled: false,
247
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
248
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
249
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail,
250
+ i18n: createWorkspacesServerI18n(),
251
+ userProfilesRepository: profiles,
252
+ userSettingsRepository: userSettings
253
+ });
254
+
241
255
  // Lists of more rows than json-rest-api's default page (20) and maximum page (100), written in bulk.
242
256
  async function insertRows(table, rows) {
243
257
  for (let start = 0; start < rows.length; start += 100) {
@@ -289,19 +303,7 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
289
303
  assert.deepEqual(listed.map(({ displayName }) => displayName), expectedNames);
290
304
  assert.equal(new Set(listed.map(({ userId }) => userId)).size, 1050);
291
305
 
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
- });
306
+ const membersService = createMembers();
305
307
  const { members } = await membersService.listMembers(workspace);
306
308
  assert.deepEqual(members.map(({ userId }) => userId), listed.map(({ userId }) => userId));
307
309
  assert.deepEqual(members.filter(({ isOwner }) => isOwner).map(({ userId }) => userId), [ownerId]);
@@ -315,6 +317,66 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
315
317
  });
316
318
  });
317
319
 
320
+ test("getMember answers a member's listMembers entry at the same cost among 10 and 200 members", async () => {
321
+ const usernames = Array.from({ length: 212 }, (_, index) => `person-${String(index).padStart(3, "0")}`);
322
+ const users = await insertUsers(usernames, (username) => username.replace("person-", "Person "));
323
+ const ownerId = users.get("person-000");
324
+ const small = await workspaces.insert({ slug: "small-team", name: "Small Team", ownerUserId: ownerId });
325
+ const large = await workspaces.insert({ slug: "large-team", name: "Large Team", ownerUserId: ownerId });
326
+ // Small: persons 0-9 (9 removed). Large: persons 0 and 10-209 (209 removed). 210 and 211 are in neither.
327
+ const membership = (workspaceId, username, status = "active") => ({
328
+ workspace_id: workspaceId,
329
+ user_id: users.get(username),
330
+ role_sid: users.get(username) === ownerId ? "owner" : "member",
331
+ status
332
+ });
333
+ await insertRows("workspace_memberships", [
334
+ ...usernames.slice(0, 10).map((username) => membership(small.id, username, username === "person-009" ? "revoked" : "active")),
335
+ membership(large.id, "person-000"),
336
+ ...usernames.slice(10, 210).map((username) => membership(large.id, username, username === "person-209" ? "revoked" : "active"))
337
+ ]);
338
+ 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
+
351
+ for (const [workspace, memberName, removedName] of [
352
+ [small, "person-004", "person-009"],
353
+ [large, "person-150", "person-209"]
354
+ ]) {
355
+ const { members } = await membersService.listMembers(workspace);
356
+ for (const userId of [users.get(memberName), ownerId]) {
357
+ const entry = members.find((member) => member.userId === userId);
358
+ assert.ok(entry);
359
+ assert.deepEqual(await membersService.getMember(workspace, userId), entry);
360
+ }
361
+ assert.equal((await membersService.getMember(workspace, ownerId)).isOwner, true);
362
+ assert.equal(await membersService.getMember(workspace, users.get(removedName)), null);
363
+ assert.equal(await membersService.getMember(workspace, users.get("person-211")), null);
364
+ assert.equal(await membersService.getMember(workspace, "999999"), null);
365
+ }
366
+ assert.equal(await membersService.getMember(small, users.get("person-150")), null);
367
+
368
+ const smallRead = await statementsOf(() => membersService.getMember(small, users.get("person-004")));
369
+ const largeRead = await statementsOf(() => membersService.getMember(large, users.get("person-150")));
370
+ assert.equal(smallRead.result.displayName, "Person 004");
371
+ assert.equal(largeRead.result.displayName, "Person 150");
372
+ assert.equal(largeRead.statements, smallRead.statements);
373
+ assert.ok(smallRead.statements <= 4, `one member read costs ${smallRead.statements} statements`);
374
+ await profiles.withTransaction(async (trx) => {
375
+ const member = await membersService.getMember(large, users.get("person-150"), { trx });
376
+ assert.equal(member.userId, users.get("person-150"));
377
+ });
378
+ });
379
+
318
380
  test("a member of 130 workspaces has all of them listed", async () => {
319
381
  const users = await insertUsers(["host", "roaming"]);
320
382
  const workspaceIds = await insertWorkspaces(135, users.get("host"));
@@ -283,7 +283,7 @@ 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
  });