@ossy/workspaces 3.11.0 → 3.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/workspaces",
3
3
  "description": "Workspaces feature package - create, select, and manage workspaces, users, and invitations",
4
- "version": "3.11.0",
4
+ "version": "3.11.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -50,5 +50,5 @@
50
50
  "/src",
51
51
  "README.md"
52
52
  ],
53
- "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
53
+ "gitHead": "f279de3535b6bc76631cbc6b943dae8f92a1565f"
54
54
  }
@@ -4,42 +4,78 @@ import { WorkspaceSchema } from './schema-ids.js'
4
4
 
5
5
  const log = createLogger('workspaces')
6
6
 
7
+ /**
8
+ * Membership events that mean the user belongs to a workspace.
9
+ * Kept narrow so the list query never scans every workspace stream.
10
+ */
11
+ const MEMBERSHIP_EVENTS = ['Created', 'UserInvitationAccepted']
12
+
7
13
  export class WorkspacesQueries {
14
+ /**
15
+ * Minified `{ id, name }[]` for workspaces the user created or joined.
16
+ *
17
+ * Pipeline shape (perf-critical for select-workspace):
18
+ * 1. `$match` only this user's membership events (not every workspace event)
19
+ * 2. `$group` by workspace id (name when the user is the creator)
20
+ * 3. `$lookup` the workspace `Created` event for name when the user only joined
21
+ */
8
22
  static getAllByUserIncluded (userId) {
9
23
  log.info('[WorkspacesQueries] getAllByUserIncluded()')
24
+ if (!userId) return Promise.resolve([])
25
+
10
26
  return EventStore.Aggregate([
11
- { $match: { type: WorkspaceSchema.workspace, resourceId: { $exists: true } } },
12
- { $group: { _id: '$resourceId', events: { $push: '$$ROOT' } } },
13
27
  {
14
28
  $match: {
15
- $or: [
16
- { 'events.createdBy': userId },
17
- { 'events.event': 'UserInvitationAccepted', 'events.createdBy': userId },
18
- ],
19
- 'events.event': { $in: ['Created', 'UserInvitationAccepted'] },
29
+ type: WorkspaceSchema.workspace,
30
+ createdBy: userId,
31
+ event: { $in: MEMBERSHIP_EVENTS },
20
32
  },
21
33
  },
22
34
  {
23
- $project: {
24
- id: '$_id',
35
+ $group: {
36
+ _id: '$resourceId',
25
37
  name: {
26
- $let: {
27
- vars: {
28
- created: {
29
- $arrayElemAt: [
30
- {
31
- $filter: {
32
- input: '$events',
33
- as: 'e',
34
- cond: { $eq: ['$$e.event', 'Created'] },
35
- },
36
- },
37
- 0,
38
+ $max: {
39
+ $cond: [
40
+ { $eq: ['$event', 'Created'] },
41
+ '$payload.name',
42
+ null,
43
+ ],
44
+ },
45
+ },
46
+ },
47
+ },
48
+ {
49
+ $lookup: {
50
+ from: 'eventstore',
51
+ let: { workspaceId: '$_id' },
52
+ pipeline: [
53
+ {
54
+ $match: {
55
+ $expr: {
56
+ $and: [
57
+ { $eq: ['$resourceId', '$$workspaceId'] },
58
+ { $eq: ['$type', WorkspaceSchema.workspace] },
59
+ { $eq: ['$event', 'Created'] },
38
60
  ],
39
61
  },
40
62
  },
41
- in: '$$created.payload.name',
42
63
  },
64
+ { $limit: 1 },
65
+ { $project: { _id: 0, name: '$payload.name' } },
66
+ ],
67
+ as: '_created',
68
+ },
69
+ },
70
+ {
71
+ $project: {
72
+ _id: 0,
73
+ id: '$_id',
74
+ name: {
75
+ $ifNull: [
76
+ '$name',
77
+ { $arrayElemAt: ['$_created.name', 0] },
78
+ ],
43
79
  },
44
80
  },
45
81
  },
@@ -0,0 +1,59 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const aggregateMock = jest.fn()
4
+
5
+ jest.unstable_mockModule('@ossy/event-store', () => ({
6
+ EventStore: {
7
+ Aggregate: aggregateMock,
8
+ },
9
+ }))
10
+
11
+ jest.unstable_mockModule('@ossy/observability', () => ({
12
+ createLogger: () => ({
13
+ info: () => {},
14
+ error: () => {},
15
+ warn: () => {},
16
+ debug: () => {},
17
+ }),
18
+ }))
19
+
20
+ const { WorkspacesQueries } = await import('./workspaces.queries.js')
21
+ const { WorkspaceSchema } = await import('./schema-ids.js')
22
+
23
+ describe('WorkspacesQueries.getAllByUserIncluded', () => {
24
+ beforeEach(() => {
25
+ aggregateMock.mockReset()
26
+ })
27
+
28
+ it('returns [] without hitting Mongo when userId is missing', async () => {
29
+ await expect(WorkspacesQueries.getAllByUserIncluded()).resolves.toEqual([])
30
+ await expect(WorkspacesQueries.getAllByUserIncluded(null)).resolves.toEqual([])
31
+ expect(aggregateMock).not.toHaveBeenCalled()
32
+ })
33
+
34
+ it('scopes the aggregation to this user membership events only', async () => {
35
+ aggregateMock.mockResolvedValue([{ id: 'ws-1', name: 'Acme' }])
36
+
37
+ await expect(WorkspacesQueries.getAllByUserIncluded('user-1')).resolves.toEqual([
38
+ { id: 'ws-1', name: 'Acme' },
39
+ ])
40
+
41
+ expect(aggregateMock).toHaveBeenCalledTimes(1)
42
+ const [pipeline] = aggregateMock.mock.calls[0]
43
+ expect(pipeline[0]).toEqual({
44
+ $match: {
45
+ type: WorkspaceSchema.workspace,
46
+ createdBy: 'user-1',
47
+ event: { $in: ['Created', 'UserInvitationAccepted'] },
48
+ },
49
+ })
50
+ expect(pipeline.some(stage => stage.$lookup?.from === 'eventstore')).toBe(true)
51
+ // Must not scan every workspace stream then filter
52
+ expect(JSON.stringify(pipeline[0])).not.toContain('events.createdBy')
53
+ })
54
+
55
+ it('returns [] when the aggregation rejects', async () => {
56
+ aggregateMock.mockRejectedValue(new Error('mongo down'))
57
+ await expect(WorkspacesQueries.getAllByUserIncluded('user-1')).resolves.toEqual([])
58
+ })
59
+ })