@tstdl/base 0.93.122 → 0.93.125

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.
Files changed (36) hide show
  1. package/api/server/gateway.js +6 -1
  2. package/api/types.d.ts +5 -1
  3. package/document-management/server/drizzle/{0000_silly_chimera.sql → 0000_complex_black_bird.sql} +1 -1
  4. package/document-management/server/drizzle/meta/0000_snapshot.json +3 -3
  5. package/document-management/server/drizzle/meta/_journal.json +2 -2
  6. package/notification/api/notification.api.d.ts +30 -4
  7. package/notification/api/notification.api.js +17 -3
  8. package/notification/client/notification-client.d.ts +6 -0
  9. package/notification/client/notification-client.js +47 -4
  10. package/notification/models/in-app-notification.model.d.ts +9 -3
  11. package/notification/models/in-app-notification.model.js +32 -11
  12. package/notification/models/notification-log.model.js +2 -3
  13. package/notification/server/api/notification.api-controller.d.ts +2 -1
  14. package/notification/server/api/notification.api-controller.js +21 -1
  15. package/notification/server/drizzle/{0000_oval_rage.sql → 0000_wise_pyro.sql} +22 -4
  16. package/notification/server/drizzle/meta/0000_snapshot.json +249 -37
  17. package/notification/server/drizzle/meta/_journal.json +2 -2
  18. package/notification/server/module.d.ts +5 -0
  19. package/notification/server/module.js +6 -1
  20. package/notification/server/providers/in-app-channel-provider.js +2 -1
  21. package/notification/server/schemas.d.ts +3 -2
  22. package/notification/server/schemas.js +3 -2
  23. package/notification/server/services/notification-sse.service.d.ts +14 -2
  24. package/notification/server/services/notification-sse.service.js +10 -11
  25. package/notification/server/services/notification.service.d.ts +16 -5
  26. package/notification/server/services/notification.service.js +160 -34
  27. package/notification/tests/notification-api.test.js +8 -1
  28. package/notification/tests/notification-flow.test.js +41 -4
  29. package/notification/tests/notification-sse.service.test.js +24 -3
  30. package/notification/types.d.ts +10 -2
  31. package/notification/types.js +6 -2
  32. package/orm/server/drizzle/schema-converter.js +5 -3
  33. package/orm/tests/schema-converter.test.js +1 -0
  34. package/package.json +1 -1
  35. package/sse/data-stream-source.d.ts +7 -4
  36. package/sse/data-stream-source.js +7 -4
@@ -6,26 +6,30 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  var _a;
8
8
  var NotificationService_1;
9
- import { and, desc, eq, isNull, lt, or } from 'drizzle-orm';
9
+ import { and, desc, eq, gt, isNull, lt, or, sql } from 'drizzle-orm';
10
+ import { match, P } from 'ts-pattern';
10
11
  import { BadRequestError } from '../../../errors/bad-request.error.js';
11
12
  import { inject, Singleton } from '../../../injector/index.js';
12
13
  import { Logger } from '../../../logger/logger.js';
13
- import { TRANSACTION_TIMESTAMP } from '../../../orm/index.js';
14
+ import { getEntityIds, TRANSACTION_TIMESTAMP } from '../../../orm/index.js';
14
15
  import { injectRepository, Transactional } from '../../../orm/server/index.js';
15
16
  import { TaskQueue } from '../../../task-queue/task-queue.js';
16
17
  import { tryIgnoreLogAsync } from '../../../utils/try-ignore.js';
17
- import { isDefined } from '../../../utils/type-guards.js';
18
- import { InAppNotification, NotificationChannel, NotificationLogEntity, NotificationPreference, NotificationPriority, NotificationStatus, toInAppNotificationView, WebPushSubscription } from '../../models/index.js';
19
- import { inAppNotification, notificationLog } from '../schemas.js';
18
+ import { isDefined, isUndefined } from '../../../utils/type-guards.js';
19
+ import { InAppNotification, InAppNotificationArchive, NotificationChannel, NotificationLogEntity, NotificationPreference, NotificationPriority, NotificationStatus, toInAppNotificationView, WebPushSubscription } from '../../models/index.js';
20
+ import { NotificationConfiguration } from '../module.js';
21
+ import { inAppNotification, inAppNotificationArchive, notificationLog } from '../schemas.js';
20
22
  import { NotificationAncillaryService } from './notification-ancillary.service.js';
21
23
  import { NotificationSseService } from './notification-sse.service.js';
22
24
  let NotificationService = NotificationService_1 = class NotificationService extends Transactional {
23
25
  #notificationLogRepository = injectRepository(NotificationLogEntity);
24
26
  #inAppNotificationRepository = injectRepository(InAppNotification);
27
+ #inAppNotificationArchiveRepository = injectRepository(InAppNotificationArchive);
25
28
  #preferenceRepository = injectRepository(NotificationPreference);
26
29
  #webPushSubscriptionRepository = injectRepository(WebPushSubscription);
27
30
  #notificationAncillaryService = inject(NotificationAncillaryService);
28
31
  #taskQueue = inject((TaskQueue), 'notification');
32
+ #configuration = inject(NotificationConfiguration);
29
33
  #sseService = inject(NotificationSseService);
30
34
  #logger = inject(Logger, NotificationService_1.name);
31
35
  async send(tenantId, userId, notification, options) {
@@ -44,61 +48,189 @@ let NotificationService = NotificationService_1 = class NotificationService exte
44
48
  });
45
49
  }
46
50
  async listInApp(tenantId, userId, options = {}) {
47
- let afterNotification;
48
- if (options.after != null) {
49
- const inApp = await this.#inAppNotificationRepository.loadByQuery({ tenantId, userId, id: options.after });
50
- afterNotification = await this.#notificationLogRepository.loadByQuery({ tenantId, userId, id: inApp.logId });
51
- }
52
- const rows = await this.#notificationLogRepository.session
51
+ return await this.#list(tenantId, userId, inAppNotification, this.#inAppNotificationRepository, options);
52
+ }
53
+ async listArchivedInApp(tenantId, userId, options = {}) {
54
+ return await this.#list(tenantId, userId, inAppNotificationArchive, this.#inAppNotificationArchiveRepository, options);
55
+ }
56
+ async #list(tenantId, userId, table, repository, options = {}) {
57
+ const db = this.#notificationLogRepository.session;
58
+ const { afterTimestamp, afterNotificationId } = await match(options.after)
59
+ .with(P.nullish, () => ({ afterTimestamp: undefined, afterNotificationId: undefined }))
60
+ .with(P.number, (value) => ({ afterTimestamp: value, afterNotificationId: undefined }))
61
+ .with(P.string, async (value) => {
62
+ const inApp = await repository.tryLoadByQuery({ tenantId, userId, id: value });
63
+ return { afterTimestamp: inApp?.timestamp, afterNotificationId: inApp?.logId };
64
+ })
65
+ .exhaustive();
66
+ const rows = await db
53
67
  .select({
54
68
  notification: notificationLog,
55
- inApp: inAppNotification,
69
+ inApp: table,
56
70
  })
57
- .from(notificationLog)
58
- .innerJoin(inAppNotification, and(eq(inAppNotification.tenantId, notificationLog.tenantId), eq(inAppNotification.userId, notificationLog.userId), eq(inAppNotification.logId, notificationLog.id)))
59
- .where(and(eq(notificationLog.tenantId, tenantId), eq(notificationLog.userId, userId), options.includeArchived ? undefined : isNull(inAppNotification.archiveTimestamp), options.unreadOnly ? isNull(inAppNotification.readTimestamp) : undefined, isDefined(afterNotification)
60
- ? or(lt(notificationLog.timestamp, afterNotification.timestamp), and(eq(notificationLog.timestamp, afterNotification.timestamp), lt(notificationLog.id, afterNotification.id)))
71
+ .from(table)
72
+ .innerJoin(notificationLog, and(eq(notificationLog.tenantId, table.tenantId), eq(notificationLog.userId, table.userId), eq(notificationLog.id, table.logId)))
73
+ .where(and(eq(table.tenantId, tenantId), eq(table.userId, userId), options.unreadOnly ? isNull(table.readTimestamp) : undefined, isDefined(afterTimestamp)
74
+ ? isDefined(afterNotificationId)
75
+ ? or(lt(table.timestamp, afterTimestamp), and(eq(table.timestamp, afterTimestamp), lt(table.logId, afterNotificationId)))
76
+ : lt(table.timestamp, afterTimestamp)
61
77
  : undefined))
62
78
  .limit(options.limit ?? 50)
63
79
  .offset(options.offset ?? 0)
64
- .orderBy(desc(notificationLog.timestamp), desc(notificationLog.id));
80
+ .orderBy(desc(table.timestamp), desc(table.logId));
65
81
  const inAppRows = rows.map((row) => row.inApp);
66
82
  const notificationRows = rows.map((row) => row.notification);
67
83
  const notificationEntities = await this.#notificationLogRepository.mapManyToEntity(notificationRows);
68
- const inAppEntities = await this.#inAppNotificationRepository.mapManyToEntity(inAppRows);
84
+ const inAppEntities = await repository.mapManyToEntity(inAppRows);
69
85
  const notificationViewDatas = await this.#notificationAncillaryService.getViewData(tenantId, notificationEntities);
70
- const views = notificationEntities.map((notification, index) => {
86
+ return notificationEntities.map((notification, index) => {
71
87
  const inApp = inAppEntities[index];
72
88
  const viewData = notificationViewDatas[index];
73
89
  const payload = { ...notification.payload, ...viewData };
74
90
  return toInAppNotificationView(inApp, { ...notification, payload });
75
91
  });
76
- return views;
77
92
  }
78
93
  async markRead(tenantId, userId, id) {
79
94
  await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, { readTimestamp: TRANSACTION_TIMESTAMP });
80
- await this.dispatchUnreadCountUpdate(tenantId, userId);
95
+ await tryIgnoreLogAsync(this.#logger, async () => {
96
+ const unreadCount = await this.unreadCount(tenantId, userId);
97
+ await this.#sseService.dispatch(tenantId, userId, { readId: id, unreadCount });
98
+ });
81
99
  }
82
100
  async markAllRead(tenantId, userId) {
83
- await this.#inAppNotificationRepository.updateByQuery({ tenantId, userId, readTimestamp: null }, { readTimestamp: TRANSACTION_TIMESTAMP });
84
- await this.dispatchUnreadCountUpdate(tenantId, userId);
101
+ await this.#inAppNotificationRepository.updateManyByQuery({ tenantId, userId, readTimestamp: null }, { readTimestamp: TRANSACTION_TIMESTAMP });
102
+ await tryIgnoreLogAsync(this.#logger, async () => {
103
+ const unreadCount = await this.unreadCount(tenantId, userId);
104
+ await this.#sseService.dispatch(tenantId, userId, { readAll: true, unreadCount });
105
+ });
85
106
  }
86
107
  async archive(tenantId, userId, id) {
87
- await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, { archiveTimestamp: TRANSACTION_TIMESTAMP });
88
- await this.dispatchUnreadCountUpdate(tenantId, userId);
108
+ const db = this.#inAppNotificationRepository.session;
109
+ const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
110
+ .where(and(eq(inAppNotification.tenantId, tenantId), eq(inAppNotification.id, id), eq(inAppNotification.userId, userId)))
111
+ .returning());
112
+ await db.with(deletedRows)
113
+ .insert(inAppNotificationArchive)
114
+ .select(db
115
+ .select({
116
+ id: deletedRows.id,
117
+ tenantId: deletedRows.tenantId,
118
+ userId: deletedRows.userId,
119
+ logId: deletedRows.logId,
120
+ timestamp: deletedRows.timestamp,
121
+ readTimestamp: deletedRows.readTimestamp,
122
+ archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
123
+ })
124
+ .from(deletedRows));
125
+ await tryIgnoreLogAsync(this.#logger, async () => {
126
+ const unreadCount = await this.unreadCount(tenantId, userId);
127
+ await this.#sseService.dispatch(tenantId, userId, { archiveId: id, unreadCount });
128
+ });
129
+ }
130
+ async archiveByQuery(tenantId, query) {
131
+ const db = this.#inAppNotificationRepository.session;
132
+ const logQueryCondition = this.#notificationLogRepository.convertQuery(query);
133
+ const rowsToDelete = db.$with('rows_to_delete').as(db.select({ id: inAppNotification.id })
134
+ .from(inAppNotification)
135
+ .innerJoin(notificationLog, and(eq(inAppNotification.tenantId, notificationLog.tenantId), eq(inAppNotification.logId, notificationLog.id), eq(inAppNotification.userId, notificationLog.userId)))
136
+ .where(and(eq(inAppNotification.tenantId, tenantId), logQueryCondition)));
137
+ const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
138
+ .where(sql `${inAppNotification.id} IN (SELECT id FROM ${rowsToDelete})`)
139
+ .returning());
140
+ await db.with(rowsToDelete, deletedRows)
141
+ .insert(inAppNotificationArchive)
142
+ .select(db
143
+ .select({
144
+ id: deletedRows.id,
145
+ tenantId: deletedRows.tenantId,
146
+ userId: deletedRows.userId,
147
+ logId: deletedRows.logId,
148
+ timestamp: deletedRows.timestamp,
149
+ readTimestamp: deletedRows.readTimestamp,
150
+ archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
151
+ })
152
+ .from(deletedRows));
89
153
  }
90
154
  async archiveAll(tenantId, userId) {
91
- await this.#inAppNotificationRepository.updateByQuery({ tenantId, userId, archiveTimestamp: null }, { archiveTimestamp: TRANSACTION_TIMESTAMP });
92
- await this.dispatchUnreadCountUpdate(tenantId, userId);
155
+ const db = this.#inAppNotificationRepository.session;
156
+ const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
157
+ .where(and(eq(inAppNotification.tenantId, tenantId), eq(inAppNotification.userId, userId)))
158
+ .returning());
159
+ await db.with(deletedRows)
160
+ .insert(inAppNotificationArchive)
161
+ .select(db
162
+ .select({
163
+ id: deletedRows.id,
164
+ tenantId: deletedRows.tenantId,
165
+ userId: deletedRows.userId,
166
+ logId: deletedRows.logId,
167
+ timestamp: deletedRows.timestamp,
168
+ readTimestamp: deletedRows.readTimestamp,
169
+ archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
170
+ })
171
+ .from(deletedRows));
172
+ await tryIgnoreLogAsync(this.#logger, async () => {
173
+ const unreadCount = await this.unreadCount(tenantId, userId);
174
+ await this.#sseService.dispatch(tenantId, userId, { archiveAll: true, unreadCount });
175
+ });
176
+ }
177
+ async runAutoArchive() {
178
+ const autoArchiveAfter = this.#configuration.autoArchiveAfter;
179
+ if (isUndefined(autoArchiveAfter) || (autoArchiveAfter <= 0)) {
180
+ return;
181
+ }
182
+ const threshold = new Date(Date.now() - autoArchiveAfter);
183
+ const db = this.#inAppNotificationRepository.session;
184
+ const rowsToDelete = db.$with('rows_to_delete').as(db.select({ id: inAppNotification.id })
185
+ .from(inAppNotification)
186
+ .where(lt(inAppNotification.timestamp, threshold.getTime())));
187
+ const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
188
+ .where(sql `${inAppNotification.id} IN (SELECT id FROM ${rowsToDelete})`)
189
+ .returning());
190
+ await db.with(rowsToDelete, deletedRows)
191
+ .insert(inAppNotificationArchive)
192
+ .select(db
193
+ .select({
194
+ id: deletedRows.id,
195
+ tenantId: deletedRows.tenantId,
196
+ userId: deletedRows.userId,
197
+ logId: deletedRows.logId,
198
+ timestamp: deletedRows.timestamp,
199
+ readTimestamp: deletedRows.readTimestamp,
200
+ archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
201
+ })
202
+ .from(deletedRows));
93
203
  }
94
204
  async unreadCount(tenantId, userId) {
95
205
  return await this.#inAppNotificationRepository.countByQuery({
96
206
  tenantId,
97
207
  userId,
98
208
  readTimestamp: null,
99
- archiveTimestamp: null,
100
209
  });
101
210
  }
211
+ async getCatchupData(tenantId, userId, stateTimestamp) {
212
+ const db = this.#notificationLogRepository.session;
213
+ const [unreadCount, missedNotifications, readRows, archiveRows] = await Promise.all([
214
+ this.unreadCount(tenantId, userId),
215
+ this.listInApp(tenantId, userId, { after: stateTimestamp, limit: 50 }),
216
+ db.select({ id: inAppNotification.id })
217
+ .from(inAppNotification)
218
+ .where(and(eq(inAppNotification.tenantId, tenantId), eq(inAppNotification.userId, userId), gt(inAppNotification.readTimestamp, stateTimestamp)))
219
+ .orderBy(desc(inAppNotification.readTimestamp))
220
+ .limit(50),
221
+ db.select({ id: inAppNotificationArchive.id })
222
+ .from(inAppNotificationArchive)
223
+ .where(and(eq(inAppNotificationArchive.tenantId, tenantId), eq(inAppNotificationArchive.userId, userId), gt(inAppNotificationArchive.archiveTimestamp, stateTimestamp)))
224
+ .orderBy(desc(inAppNotificationArchive.archiveTimestamp))
225
+ .limit(50),
226
+ ]);
227
+ return {
228
+ unreadCount,
229
+ missedNotifications,
230
+ readIds: getEntityIds(readRows),
231
+ archiveIds: getEntityIds(archiveRows),
232
+ };
233
+ }
102
234
  async getPreferences(tenantId, userId) {
103
235
  return await this.#preferenceRepository.loadManyByQuery({ tenantId, userId });
104
236
  }
@@ -132,12 +264,6 @@ let NotificationService = NotificationService_1 = class NotificationService exte
132
264
  auth,
133
265
  });
134
266
  }
135
- async dispatchUnreadCountUpdate(tenantId, userId) {
136
- await tryIgnoreLogAsync(this.#logger, async () => {
137
- const unreadCount = await this.unreadCount(tenantId, userId);
138
- await this.#sseService.dispatchUnreadCountUpdate(tenantId, userId, unreadCount);
139
- });
140
- }
141
267
  };
142
268
  NotificationService = NotificationService_1 = __decorate([
143
269
  Singleton()
@@ -44,6 +44,7 @@ describe('Notification API (Integration)', () => {
44
44
  const createMockContext = (params = {}) => ({
45
45
  parameters: params,
46
46
  abortSignal: new AbortController().signal,
47
+ serverSentEvents: {},
47
48
  getToken: async () => ({
48
49
  payload: {
49
50
  tenant: tenantId,
@@ -74,10 +75,16 @@ describe('Notification API (Integration)', () => {
74
75
  });
75
76
  test('listInApp should call service', async () => {
76
77
  const listInAppSpy = vi.spyOn(notificationService, 'listInApp').mockResolvedValue([]);
77
- const params = { limit: 10, offset: 0, includeArchived: false };
78
+ const params = { limit: 10, offset: 0 };
78
79
  await controller.listInApp(createMockContext(params));
79
80
  expect(listInAppSpy).toHaveBeenCalledWith(tenantId, userId, params);
80
81
  });
82
+ test('listArchivedInApp should call service', async () => {
83
+ const listArchivedInAppSpy = vi.spyOn(notificationService, 'listArchivedInApp').mockResolvedValue([]);
84
+ const params = { limit: 10, offset: 0 };
85
+ await controller.listArchivedInApp(createMockContext(params));
86
+ expect(listArchivedInAppSpy).toHaveBeenCalledWith(tenantId, userId, params);
87
+ });
81
88
  test('markRead should call service', async () => {
82
89
  const markReadSpy = vi.spyOn(notificationService, 'markRead').mockResolvedValue();
83
90
  const params = { id: 'notif-id' };
@@ -64,7 +64,7 @@ describe('Notification Flow (Integration)', () => {
64
64
  });
65
65
  beforeEach(async () => {
66
66
  await truncateTables(database, 'authentication', ['user', 'subject']);
67
- await truncateTables(database, 'notification', ['log', 'in_app', 'type', 'preference', 'web_push_subscription']);
67
+ await truncateTables(database, 'notification', ['log', 'in_app', 'in_app_archive', 'type', 'preference', 'web_push_subscription']);
68
68
  vi.clearAllMocks();
69
69
  });
70
70
  test('should execute full notification flow with escalation', async () => {
@@ -234,11 +234,11 @@ describe('Notification Flow (Integration)', () => {
234
234
  expect(list[0].readTimestamp).not.toBeNull();
235
235
  // Archive
236
236
  await notificationService.archive(tenantId, user.id, list[0].id);
237
- // List (default excludes archived)
237
+ // List (excludes archived)
238
238
  list = await notificationService.listInApp(tenantId, user.id);
239
239
  expect(list).toHaveLength(0);
240
- // List (include archived)
241
- list = await notificationService.listInApp(tenantId, user.id, { includeArchived: true });
240
+ // List archived
241
+ list = await notificationService.listArchivedInApp(tenantId, user.id);
242
242
  expect(list).toHaveLength(1);
243
243
  expect(list[0].archiveTimestamp).not.toBeNull();
244
244
  });
@@ -293,4 +293,41 @@ describe('Notification Flow (Integration)', () => {
293
293
  expect(afterSecond[0].id).toBe(list[2].id);
294
294
  });
295
295
  });
296
+ test('should auto-archive old notifications', async () => {
297
+ await runInInjectionContext(injector, async () => {
298
+ const logRepo = injectRepository(NotificationLogEntity);
299
+ const user = await subjectService.createUser({ tenantId, email: 'auto@example.com', firstName: 'Auto', lastName: 'User' });
300
+ await typeService.initializeTypes({ test: { label: 'Auto Test' } });
301
+ await notificationService.send(tenantId, user.id, { type: 'test', triggerSubjectId: user.id, payload: {} });
302
+ const logs = await logRepo.loadManyByQuery({ tenantId });
303
+ await worker.deliver(logs[0].id);
304
+ // Verify active
305
+ expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(1);
306
+ // Manually update timestamp to be old (31 days ago)
307
+ const oldTimestamp = Date.now() - 31 * 24 * 60 * 60 * 1000;
308
+ await logRepo.updateByQuery({ id: logs[0].id }, { timestamp: oldTimestamp });
309
+ await notificationService.runAutoArchive();
310
+ // Verify archived
311
+ expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(0);
312
+ expect(await notificationService.listArchivedInApp(tenantId, user.id)).toHaveLength(1);
313
+ vi.useRealTimers();
314
+ });
315
+ });
316
+ test('should archive all notifications for a user', async () => {
317
+ await runInInjectionContext(injector, async () => {
318
+ const logRepo = injectRepository(NotificationLogEntity);
319
+ const user = await subjectService.createUser({ tenantId, email: 'archiveall@example.com', firstName: 'Archive', lastName: 'All' });
320
+ await typeService.initializeTypes({ test: { label: 'Archive All Test' } });
321
+ await notificationService.send(tenantId, user.id, { type: 'test', triggerSubjectId: user.id, payload: {} });
322
+ await notificationService.send(tenantId, user.id, { type: 'test', triggerSubjectId: user.id, payload: {} });
323
+ const logs = await logRepo.loadManyByQuery({ tenantId });
324
+ for (const log of logs) {
325
+ await worker.deliver(log.id);
326
+ }
327
+ expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(2);
328
+ await notificationService.archiveAll(tenantId, user.id);
329
+ expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(0);
330
+ expect(await notificationService.listArchivedInApp(tenantId, user.id)).toHaveLength(2);
331
+ });
332
+ });
296
333
  });
@@ -13,8 +13,8 @@ describe('NotificationSseService', () => {
13
13
  expect(source).toBeDefined();
14
14
  // We can't easily spy on the LocalMessageBus internals without more complex setup,
15
15
  // but we can verify that sending doesn't throw.
16
- const msg = { tenantId, userId, logId: 'l1' };
17
- await expect(service.send(msg, 1)).resolves.not.toThrow();
16
+ const msg = { id: 'l1', tenantId, userId, logId: 'l1' };
17
+ await expect(service.dispatch(tenantId, userId, { notification: msg, unreadCount: 1 })).resolves.not.toThrow();
18
18
  });
19
19
  });
20
20
  test('should dispatch unread count update', async () => {
@@ -23,7 +23,28 @@ describe('NotificationSseService', () => {
23
23
  const tenantId = 't1';
24
24
  const userId = 'u1';
25
25
  await runInInjectionContext(injector, async () => {
26
- await expect(service.dispatchUnreadCountUpdate(tenantId, userId, 5)).resolves.not.toThrow();
26
+ const source = service.register(tenantId, userId);
27
+ const messages = [];
28
+ source.subscribe((msg) => messages.push(msg));
29
+ await service.dispatch(tenantId, userId, { unreadCount: 5 });
30
+ expect(messages).toHaveLength(1);
31
+ expect(messages[0]).toEqual({ unreadCount: 5 });
32
+ });
33
+ });
34
+ test('should dispatch mark read and mark all read', async () => {
35
+ const { injector } = await setupIntegrationTest({ modules: { messageBus: true, signals: true } });
36
+ const service = injector.resolve(NotificationSseService);
37
+ const tenantId = 't1';
38
+ const userId = 'u1';
39
+ await runInInjectionContext(injector, async () => {
40
+ const source = service.register(tenantId, userId);
41
+ const messages = [];
42
+ source.subscribe((msg) => messages.push(msg));
43
+ await service.dispatch(tenantId, userId, { readId: 'n1', unreadCount: 2 });
44
+ await service.dispatch(tenantId, userId, { readAll: true, unreadCount: 0 });
45
+ expect(messages).toHaveLength(2);
46
+ expect(messages[0]).toEqual({ readId: 'n1', unreadCount: 2 });
47
+ expect(messages[1]).toEqual({ readAll: true, unreadCount: 0 });
27
48
  });
28
49
  });
29
50
  });
@@ -1,9 +1,17 @@
1
1
  import { InAppNotificationView, type NotificationDefinitionMap } from './models/index.js';
2
2
  export declare const notificationStreamItemSchema: import("../schema/index.js").ObjectSchema<{
3
- unreadCount: number;
4
3
  notification?: InAppNotificationView<Record<string, import("./models/notification-log.model.js").NotificationDefinition<import("../types/types.js").ObjectLiteral, import("../types/types.js").ObjectLiteral>>> | undefined;
4
+ readId?: string | undefined;
5
+ readAll?: boolean | undefined;
6
+ archiveId?: string | undefined;
7
+ archiveAll?: boolean | undefined;
8
+ unreadCount?: number | undefined;
5
9
  }>;
6
10
  export type NotificationStreamItem<Definitions extends NotificationDefinitionMap = NotificationDefinitionMap> = {
7
11
  notification?: InAppNotificationView<Definitions>;
8
- unreadCount: number;
12
+ readId?: string;
13
+ readAll?: boolean;
14
+ archiveId?: string;
15
+ archiveAll?: boolean;
16
+ unreadCount?: number;
9
17
  };
@@ -1,6 +1,10 @@
1
- import { number, object, optional } from '../schema/index.js';
1
+ import { boolean, number, object, optional, string } from '../schema/index.js';
2
2
  import { InAppNotificationView } from './models/index.js';
3
3
  export const notificationStreamItemSchema = object({
4
4
  notification: optional(InAppNotificationView),
5
- unreadCount: number(),
5
+ readId: optional(string()),
6
+ readAll: optional(boolean()),
7
+ archiveId: optional(string()),
8
+ archiveAll: optional(boolean()),
9
+ unreadCount: optional(number()),
6
10
  });
@@ -86,7 +86,8 @@ export function _getDrizzleTableFromType(type, fallbackSchemaName) {
86
86
  return column;
87
87
  });
88
88
  const indexFn = (data.options?.unique == true) ? uniqueIndex : index;
89
- let builder = indexFn(data.options?.name ?? getIndexName(tableName, columns, { naming: data.options?.naming })).using(data.options?.using ?? 'btree', ...columns);
89
+ let builder = indexFn(data.options?.name ?? getIndexName(tableName, columns, { naming: data.options?.naming, partial: isDefined(data.options?.where) }))
90
+ .using(data.options?.using ?? 'btree', ...columns);
90
91
  if (isDefined(data.options?.where)) {
91
92
  const query = convertQuery(data.options.where(table), table, columnDefinitionsMap);
92
93
  builder = builder.where(query.inlineParams());
@@ -198,7 +199,7 @@ export function _getDrizzleTableFromType(type, fallbackSchemaName) {
198
199
  const paradeOptions = columnDef.reflectionData.paradeField;
199
200
  return buildParadeCast(columnSql, paradeOptions);
200
201
  });
201
- const indexName = getIndexName(tableName, 'parade', { naming });
202
+ const indexName = getIndexName(tableName, 'parade', { naming, partial: isDefined(where) });
202
203
  const indexColumns = [
203
204
  table.id, // this orm always uses 'id' as key field as every entity has it
204
205
  ...classLevelColumnSqls,
@@ -468,7 +469,8 @@ function getPrimaryKeyName(tableName, columnsOrBaseName, options) {
468
469
  return getIdentifier(tableName, columnsOrBaseName, 'pk', options);
469
470
  }
470
471
  function getIndexName(tableName, columnsOrBaseName, options) {
471
- return getIdentifier(tableName, columnsOrBaseName, 'idx', options);
472
+ const suffix = (options?.partial == true) ? 'partial_idx' : 'idx';
473
+ return getIdentifier(tableName, columnsOrBaseName, suffix, options);
472
474
  }
473
475
  function getUniqueName(tableName, columnsOrBaseName, options) {
474
476
  return getIdentifier(tableName, columnsOrBaseName, 'unique', options);
@@ -42,6 +42,7 @@ describe('ORM Schema Generation - Detailed Configurations', () => {
42
42
  expect(config.indexes.length).toBeGreaterThan(0);
43
43
  const idx = config.indexes[0];
44
44
  expect(idx).toBeDefined();
45
+ expect(idx.config.name).toContain('partial_idx');
45
46
  expect(idx.config.where).toBeDefined();
46
47
  });
47
48
  test('should handle index with nulls ordering', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.122",
3
+ "version": "0.93.125",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -2,20 +2,23 @@ import type { UndefinableJson } from '../types/types.js';
2
2
  import type { AnyIterable } from '../utils/any-iterable-iterator.js';
3
3
  import { ServerSentEventsSource } from './server-sent-events-source.js';
4
4
  export type DataStreamErrorFormatter = (error: unknown) => UndefinableJson;
5
- export type DataStreamSourceOptions = {
5
+ export type DataStreamSourceOptions<T> = {
6
6
  /**
7
7
  * Whether to send deltas (the changes) between the last and current data or always the full data.
8
+ * Useful when the target data is a full "state object".
9
+ * For notification-like streams, where only new items are sent, delta should usually be disabled.
8
10
  */
9
11
  delta?: boolean;
10
12
  errorFormatter?: DataStreamErrorFormatter;
13
+ idProvider?: (data: T) => string | undefined;
11
14
  };
12
15
  export declare class DataStreamSource<T> {
13
16
  #private;
14
17
  readonly eventSource: ServerSentEventsSource;
15
18
  readonly closed: import("../signals/api.js").Signal<boolean>;
16
- constructor({ delta, errorFormatter }?: DataStreamSourceOptions);
17
- static fromIterable<T>(iterable: AnyIterable<T>, options?: DataStreamSourceOptions): DataStreamSource<T>;
18
- send(data: T): Promise<void>;
19
+ constructor({ delta, errorFormatter, idProvider }?: DataStreamSourceOptions<T>);
20
+ static fromIterable<T>(iterable: AnyIterable<T>, options?: DataStreamSourceOptions<T>): DataStreamSource<T>;
21
+ send(data: T, id?: string): Promise<void>;
19
22
  close(): Promise<void>;
20
23
  error(error: unknown): Promise<void>;
21
24
  }
@@ -16,12 +16,14 @@ const jsonDiffPatch = createDiffPatch({
16
16
  export class DataStreamSource {
17
17
  #useDelta;
18
18
  #errorFormatter;
19
+ #idProvider;
19
20
  eventSource = new ServerSentEventsSource();
20
21
  closed = this.eventSource.closed;
21
22
  #lastData;
22
- constructor({ delta = true, errorFormatter } = {}) {
23
+ constructor({ delta = true, errorFormatter, idProvider } = {}) {
23
24
  this.#useDelta = delta;
24
25
  this.#errorFormatter = errorFormatter ?? defaultErrorFormatter;
26
+ this.#idProvider = idProvider;
25
27
  }
26
28
  static fromIterable(iterable, options) {
27
29
  const source = new DataStreamSource(options);
@@ -43,16 +45,17 @@ export class DataStreamSource {
43
45
  })();
44
46
  return source;
45
47
  }
46
- async send(data) {
48
+ async send(data, id) {
47
49
  if (this.eventSource.closed()) {
48
50
  throw new Error('Cannot send data to a closed DataStreamSource connection.');
49
51
  }
52
+ const eventId = id ?? this.#idProvider?.(data);
50
53
  if (this.#useDelta && isDefined(this.#lastData)) {
51
54
  const delta = jsonDiffPatch.diff(this.#lastData, data);
52
- await this.eventSource.sendJson({ name: 'delta', data: delta });
55
+ await this.eventSource.sendJson({ name: 'delta', data: delta, id: eventId });
53
56
  }
54
57
  else {
55
- await this.eventSource.sendJson({ name: 'data', data });
58
+ await this.eventSource.sendJson({ name: 'data', data, id: eventId });
56
59
  }
57
60
  if (this.#useDelta) {
58
61
  this.#lastData = data;