@fitzzero/quickdraw-core 3.9.1 → 4.1.0

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.
@@ -11,6 +11,21 @@ import * as path from 'path';
11
11
  function channelEventName(serviceName, channelName) {
12
12
  return `channel:${serviceName}:${channelName}`;
13
13
  }
14
+ function serviceRoom(serviceName, entryId) {
15
+ return `${serviceName}:${entryId}`;
16
+ }
17
+ function serviceFullRoom(serviceName, entryId) {
18
+ return `${serviceRoom(serviceName, entryId)}:full`;
19
+ }
20
+ function collectionRoom(serviceName, collection, scopeId) {
21
+ return `${serviceName}:collection:${collection}:${scopeId}`;
22
+ }
23
+ function collectionEventName(serviceName, collection, scopeId) {
24
+ return collectionRoom(serviceName, collection, scopeId);
25
+ }
26
+ function userRoom(userId) {
27
+ return `user:${userId}`;
28
+ }
14
29
  var consoleLogger = {
15
30
  info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? ""),
16
31
  warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? ""),
@@ -19,6 +34,219 @@ var consoleLogger = {
19
34
  child: () => consoleLogger
20
35
  };
21
36
 
37
+ // src/server/collections.ts
38
+ var DEFAULT_PAGE_LIMIT = 100;
39
+ var MAX_PAGE_LIMIT = 500;
40
+ var MAX_IDS = 5e3;
41
+ var CollectionManager = class {
42
+ definitions = /* @__PURE__ */ new Map();
43
+ serviceName;
44
+ getIo;
45
+ logger;
46
+ defaultToItem;
47
+ constructor(options) {
48
+ this.serviceName = options.serviceName;
49
+ this.getIo = options.getIo;
50
+ this.logger = options.logger;
51
+ this.defaultToItem = options.defaultToItem;
52
+ }
53
+ define(name, config) {
54
+ if (this.definitions.has(name)) {
55
+ throw new Error(`${this.serviceName}: collection "${name}" is already defined`);
56
+ }
57
+ this.definitions.set(name, config);
58
+ }
59
+ getAll() {
60
+ return this.definitions;
61
+ }
62
+ get size() {
63
+ return this.definitions.size;
64
+ }
65
+ // ===========================================================================
66
+ // Wire operations (called by BaseService on behalf of the registry)
67
+ // ===========================================================================
68
+ /**
69
+ * Handle a `{service}:collection:subscribe`.
70
+ *
71
+ * Cursor-less: ACL check, join the scope room, snapshot with `ids`.
72
+ * Cursor-bearing: pure paging — ACL check, no room join, `ids` stripped
73
+ * (pruning against a partial page would drop valid cached rows).
74
+ *
75
+ * Returns `null` on access denial or unknown collection (indistinguishable
76
+ * to the client, mirroring entity subscribe semantics).
77
+ */
78
+ async subscribe(payload, socket) {
79
+ const definition = this.definitions.get(payload.collection);
80
+ if (!definition) {
81
+ this.logger.debug(`Unknown collection ${this.serviceName}:${payload.collection}`);
82
+ return null;
83
+ }
84
+ if (!socket.userId) return null;
85
+ const allowed = await definition.checkScopeAccess(socket.userId, payload.scopeId, socket);
86
+ if (!allowed) return null;
87
+ const cursor = payload.cursor ?? null;
88
+ if (cursor === null) {
89
+ void socket.join(collectionRoom(this.serviceName, payload.collection, payload.scopeId));
90
+ }
91
+ const rev = Date.now();
92
+ const limit = Math.min(
93
+ Math.max(payload.limit ?? definition.defaultLimit ?? DEFAULT_PAGE_LIMIT, 1),
94
+ MAX_PAGE_LIMIT
95
+ );
96
+ const page = await definition.snapshot(payload.scopeId, {
97
+ cursor,
98
+ limit,
99
+ userId: socket.userId
100
+ });
101
+ const response = { ...page, rev };
102
+ if (cursor !== null) {
103
+ delete response.ids;
104
+ delete response.idsTruncated;
105
+ } else if (response.ids && response.ids.length > MAX_IDS) {
106
+ delete response.ids;
107
+ response.idsTruncated = true;
108
+ }
109
+ return response;
110
+ }
111
+ /** Handle a `{service}:collection:unsubscribe` — leave the scope room. */
112
+ unsubscribe(payload, socket) {
113
+ void socket.leave(collectionRoom(this.serviceName, payload.collection, payload.scopeId));
114
+ }
115
+ // ===========================================================================
116
+ // Automatic emission (the single choke point for the CRUD trio)
117
+ // ===========================================================================
118
+ /**
119
+ * Turn a write event into deltas for every collection on the service.
120
+ *
121
+ * - create: `added` to each scope of `after`.
122
+ * - update: diff scopes of `before` vs `after` — in both → `updated`,
123
+ * `after` only → `added`, `before` only → `removed`. Scope moves and
124
+ * predicate entry/exit fall out for free.
125
+ * - delete: `removed` to each scope of `before`.
126
+ *
127
+ * Errors in app-provided callbacks are logged, never thrown — the write
128
+ * has already committed; emission failures must not fail the response.
129
+ */
130
+ async notify(event) {
131
+ if (this.definitions.size === 0) return;
132
+ for (const [name, definition] of this.definitions) {
133
+ try {
134
+ await this.notifyOne(name, definition, event);
135
+ } catch (error) {
136
+ this.logger.error(`Collection emission failed for ${this.serviceName}:${name}`, {
137
+ eventType: event.type,
138
+ error: error instanceof Error ? error.message : String(error)
139
+ });
140
+ }
141
+ }
142
+ }
143
+ async notifyOne(name, definition, event) {
144
+ switch (event.type) {
145
+ case "create": {
146
+ const scopes = normalizeScopes(await definition.resolveScopeId(event.after));
147
+ if (scopes.length === 0) return;
148
+ const item = await this.toItem(definition, event.after);
149
+ const rev = this.revFor(definition, item);
150
+ for (const scopeId of scopes) {
151
+ this.emitDelta(name, scopeId, { type: "added", item, rev });
152
+ }
153
+ return;
154
+ }
155
+ case "update": {
156
+ const beforeScopes = event.before ? normalizeScopes(await definition.resolveScopeId(event.before)) : [];
157
+ const afterScopes = normalizeScopes(await definition.resolveScopeId(event.after));
158
+ const beforeSet = new Set(beforeScopes);
159
+ const afterSet = new Set(afterScopes);
160
+ const removed = beforeScopes.filter((s) => !afterSet.has(s));
161
+ const entered = afterScopes.filter((s) => !beforeSet.has(s));
162
+ const stayed = afterScopes.filter((s) => beforeSet.has(s));
163
+ if (entered.length > 0 || stayed.length > 0) {
164
+ const item = await this.toItem(definition, event.after);
165
+ const rev = this.revFor(definition, item);
166
+ for (const scopeId of stayed) {
167
+ this.emitDelta(name, scopeId, { type: "updated", item, rev });
168
+ }
169
+ for (const scopeId of entered) {
170
+ this.emitDelta(name, scopeId, { type: "added", item, rev });
171
+ }
172
+ }
173
+ for (const scopeId of removed) {
174
+ this.emitDelta(name, scopeId, { type: "removed", id: event.after.id, rev: Date.now() });
175
+ }
176
+ return;
177
+ }
178
+ case "delete": {
179
+ const scopes = normalizeScopes(await definition.resolveScopeId(event.before));
180
+ for (const scopeId of scopes) {
181
+ this.emitDelta(name, scopeId, { type: "removed", id: event.before.id, rev: Date.now() });
182
+ }
183
+ }
184
+ }
185
+ }
186
+ // ===========================================================================
187
+ // Manual choke points (for hand-rolled write paths)
188
+ // ===========================================================================
189
+ /** Emit `updated` (client upserts; added-vs-updated is cosmetic). */
190
+ emitUpsert(collection, scopeId, item) {
191
+ const definition = this.definitions.get(collection);
192
+ const rev = definition ? this.revFor(definition, item) : Date.now();
193
+ this.emitDelta(collection, scopeId, { type: "updated", item, rev });
194
+ }
195
+ emitRemove(collection, scopeId, id) {
196
+ this.emitDelta(collection, scopeId, { type: "removed", id, rev: Date.now() });
197
+ }
198
+ /** A move is removed-from-old + added-to-new. */
199
+ emitMove(collection, fromScopeId, toScopeId, item) {
200
+ const definition = this.definitions.get(collection);
201
+ const rev = definition ? this.revFor(definition, item) : Date.now();
202
+ this.emitDelta(collection, fromScopeId, { type: "removed", id: item.id, rev });
203
+ this.emitDelta(collection, toScopeId, { type: "added", item, rev });
204
+ }
205
+ /** Clients re-snapshot (debounced) — for bulk ops and mass reorders. */
206
+ emitReset(collection, scopeId) {
207
+ this.emitDelta(collection, scopeId, { type: "reset", rev: Date.now() });
208
+ }
209
+ /**
210
+ * Force sockets out of a scope room — ACL revocation that works across
211
+ * nodes (`socketsLeave` is adapter-safe). With `userId`, only that user's
212
+ * sockets are kicked; without, the whole room is cleared.
213
+ */
214
+ kickFromCollection(collection, scopeId, userId) {
215
+ const io = this.getIo();
216
+ if (!io) {
217
+ this.logger.debug(`No io instance; cannot kick from ${this.serviceName}:${collection}`);
218
+ return;
219
+ }
220
+ const room = collectionRoom(this.serviceName, collection, scopeId);
221
+ io.in(userId ? userRoom(userId) : room).socketsLeave(room);
222
+ }
223
+ // ===========================================================================
224
+ // Internals
225
+ // ===========================================================================
226
+ async toItem(definition, entity) {
227
+ return definition.toItem ? await definition.toItem(entity) : await this.defaultToItem(entity);
228
+ }
229
+ revFor(definition, item) {
230
+ return definition.revOf?.(item) ?? Date.now();
231
+ }
232
+ emitDelta(collection, scopeId, delta) {
233
+ const io = this.getIo();
234
+ if (!io) {
235
+ this.logger.debug(
236
+ `No io instance; dropping ${delta.type} delta for ${this.serviceName}:${collection}:${scopeId}`
237
+ );
238
+ return;
239
+ }
240
+ const room = collectionRoom(this.serviceName, collection, scopeId);
241
+ io.to(room).emit(collectionEventName(this.serviceName, collection, scopeId), delta);
242
+ }
243
+ };
244
+ function normalizeScopes(scopes) {
245
+ if (scopes === null) return [];
246
+ if (typeof scopes === "string") return [scopes];
247
+ return [...new Set(scopes)];
248
+ }
249
+
22
250
  // src/server/utils/zodToAdminFields.ts
23
251
  var NON_EDITABLE_FIELDS = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt", "created_at", "updated_at"]);
24
252
  var DEFAULT_HIDDEN_FIELDS = /* @__PURE__ */ new Set(["acl", "serviceAccess", "service_access"]);
@@ -166,12 +394,14 @@ function mergeWithDefaultFields(schemaFields, options = {}) {
166
394
  }
167
395
 
168
396
  // src/server/BaseService.ts
169
- var BaseService = class {
397
+ var BaseService = class _BaseService {
170
398
  serviceName;
171
399
  hasEntryACL;
172
400
  defaultACL;
173
401
  logger;
174
- // Subscription tracking: entryId -> Set of sockets
402
+ // Subscription tracking: entryId -> Set of sockets.
403
+ // Retained for admin introspection (adminGetSubscribers) only — emission
404
+ // is room-based and never reads this map.
175
405
  subscribers = /* @__PURE__ */ new Map();
176
406
  // Prisma delegate for DB operations
177
407
  delegate;
@@ -181,6 +411,8 @@ var BaseService = class {
181
411
  publicMethods = /* @__PURE__ */ new Map();
182
412
  // Collection of channels for registry discovery
183
413
  publicChannels = /* @__PURE__ */ new Map();
414
+ // Scope-keyed collections (definitions, snapshot dispatch, delta emission)
415
+ collections;
184
416
  // Admin metadata configuration (set by installAdminMethods)
185
417
  adminMeta = null;
186
418
  constructor(options) {
@@ -188,6 +420,12 @@ var BaseService = class {
188
420
  this.hasEntryACL = options.hasEntryACL ?? false;
189
421
  this.defaultACL = options.defaultACL ?? [];
190
422
  this.logger = options.logger?.child({ service: this.serviceName }) ?? consoleLogger.child({ service: this.serviceName });
423
+ this.collections = new CollectionManager({
424
+ serviceName: this.serviceName,
425
+ getIo: () => this.io,
426
+ logger: this.logger,
427
+ defaultToItem: (entity) => this.toDto(entity)
428
+ });
191
429
  }
192
430
  /**
193
431
  * Set the Prisma delegate for this service.
@@ -220,14 +458,20 @@ var BaseService = class {
220
458
  /**
221
459
  * Get the Socket.io room name for an entry.
222
460
  * Used for room-based broadcasting across services.
461
+ * Static counterpart: `serviceRoom(serviceName, entryId)` from the root export.
223
462
  */
224
463
  getRoomName(entryId) {
225
- return `${this.serviceName}:${entryId}`;
464
+ return serviceRoom(this.serviceName, entryId);
226
465
  }
227
466
  /**
228
467
  * Subscribe a socket to an entity's updates.
229
- * Returns the current entity data if access is granted, null otherwise.
230
- * Also joins the socket to the entity's Socket.io room for cross-service events.
468
+ * Returns the current entity data (as the wire DTO, filtered for the
469
+ * subscriber's tier) if access is granted, null otherwise.
470
+ *
471
+ * Joins the entity's Socket.io room; subscribers with elevated access
472
+ * additionally join `{room}:full` so `emitUpdate` can address the two
473
+ * tiers as rooms (adapter-safe). The tier is fixed here, at subscribe
474
+ * time — an access change takes effect on re-subscribe.
231
475
  */
232
476
  async subscribe(entryId, socket, requiredLevel = "Read") {
233
477
  if (!socket.userId) {
@@ -248,10 +492,13 @@ var BaseService = class {
248
492
  this.subscribers.get(entryId).add(socket);
249
493
  const roomName = this.getRoomName(entryId);
250
494
  void socket.join(roomName);
495
+ if (this.hasElevatedAccess(socket, entryId)) {
496
+ void socket.join(serviceFullRoom(this.serviceName, entryId));
497
+ }
251
498
  this.logger.debug(`User ${socket.userId} subscribed to ${entryId} (room: ${roomName})`);
252
499
  const entity = await this.findById(entryId);
253
500
  if (!entity) return null;
254
- return this.filterEntityForSubscriber(entity, socket, entryId);
501
+ return this.filterEntityForSubscriber(await this.toDto(entity), socket, entryId);
255
502
  }
256
503
  /**
257
504
  * Subscribe a socket to multiple entities at once.
@@ -273,9 +520,9 @@ var BaseService = class {
273
520
  );
274
521
  const allowedIds = entryIds.filter((id) => accessMap.get(id) === true);
275
522
  const entities = allowedIds.length > 0 ? await this.findByIds(allowedIds) : [];
276
- const entityMap = /* @__PURE__ */ new Map();
523
+ const dtoMap = /* @__PURE__ */ new Map();
277
524
  for (const entity of entities) {
278
- entityMap.set(entity.id, entity);
525
+ dtoMap.set(entity.id, await this.toDto(entity));
279
526
  }
280
527
  for (const entryId of allowedIds) {
281
528
  if (!this.subscribers.has(entryId)) {
@@ -283,15 +530,18 @@ var BaseService = class {
283
530
  }
284
531
  this.subscribers.get(entryId).add(socket);
285
532
  void socket.join(this.getRoomName(entryId));
533
+ if (this.hasElevatedAccess(socket, entryId)) {
534
+ void socket.join(serviceFullRoom(this.serviceName, entryId));
535
+ }
286
536
  }
287
537
  const results = {};
288
538
  for (const entryId of entryIds) {
289
- const entity = entityMap.get(entryId);
290
- if (!entity || !accessMap.get(entryId)) {
539
+ const dto = dtoMap.get(entryId);
540
+ if (!dto || !accessMap.get(entryId)) {
291
541
  results[entryId] = null;
292
542
  continue;
293
543
  }
294
- results[entryId] = this.filterEntityForSubscriber(entity, socket, entryId);
544
+ results[entryId] = this.filterEntityForSubscriber(dto, socket, entryId);
295
545
  }
296
546
  this.logger.debug(
297
547
  `User ${socket.userId} batch-subscribed to ${allowedIds.length}/${entryIds.length} entities`
@@ -337,8 +587,8 @@ var BaseService = class {
337
587
  this.subscribers.delete(entryId);
338
588
  }
339
589
  }
340
- const roomName = this.getRoomName(entryId);
341
- void socket.leave(roomName);
590
+ void socket.leave(this.getRoomName(entryId));
591
+ void socket.leave(serviceFullRoom(this.serviceName, entryId));
342
592
  }
343
593
  /**
344
594
  * Remove a socket from all subscriptions (called on disconnect).
@@ -347,8 +597,8 @@ var BaseService = class {
347
597
  for (const [entryId, sockets] of this.subscribers.entries()) {
348
598
  if (sockets.has(socket)) {
349
599
  sockets.delete(socket);
350
- const roomName = this.getRoomName(entryId);
351
- void socket.leave(roomName);
600
+ void socket.leave(this.getRoomName(entryId));
601
+ void socket.leave(serviceFullRoom(this.serviceName, entryId));
352
602
  if (sockets.size === 0) {
353
603
  this.subscribers.delete(entryId);
354
604
  }
@@ -365,29 +615,29 @@ var BaseService = class {
365
615
  * @param eventName - The event name to emit
366
616
  * @param data - The data to emit
367
617
  *
618
+ * Event names and payloads are typed via the augmentable
619
+ * `QuickdrawEventMap` — with no augmentation this degrades to
620
+ * `string`/`unknown` exactly as before.
621
+ *
368
622
  * @example
369
623
  * ```typescript
370
624
  * // In MessageService, notify chat subscribers of a new message
371
625
  * this.emitToRoom(
372
- * `chatService:${message.chatId}`,
626
+ * serviceRoom('chatService', message.chatId),
373
627
  * 'chat:message',
374
628
  * messageDTO
375
629
  * );
376
630
  * ```
377
631
  */
378
632
  emitToRoom(roomName, eventName, data) {
379
- if (this.io) {
380
- this.io.to(roomName).emit(eventName, data);
381
- this.logger.debug(`Emitted ${eventName} to room ${roomName}`);
382
- return;
383
- }
384
- const anySocket = this.getAnySubscriberSocket();
385
- if (!anySocket) {
386
- this.logger.debug(`No sockets available to emit to room ${roomName}`);
633
+ if (!this.io) {
634
+ this.logger.debug(
635
+ `No io instance (service not registered); dropping ${eventName} to room ${roomName}`
636
+ );
387
637
  return;
388
638
  }
389
- anySocket.to(roomName).emit(eventName, data);
390
- this.logger.debug(`Emitted ${eventName} to room ${roomName} (via socket fallback)`);
639
+ this.io.to(roomName).emit(eventName, data);
640
+ this.logger.debug(`Emitted ${eventName} to room ${roomName}`);
391
641
  }
392
642
  /**
393
643
  * Emit a custom event to a room using Socket.io's volatile flag.
@@ -411,13 +661,8 @@ var BaseService = class {
411
661
  * ```
412
662
  */
413
663
  emitToRoomVolatile(roomName, eventName, data) {
414
- if (this.io) {
415
- this.io.to(roomName).volatile.emit(eventName, data);
416
- return;
417
- }
418
- const anySocket = this.getAnySubscriberSocket();
419
- if (!anySocket) return;
420
- anySocket.to(roomName).volatile.emit(eventName, data);
664
+ if (!this.io) return;
665
+ this.io.to(roomName).volatile.emit(eventName, data);
421
666
  }
422
667
  /**
423
668
  * Emit a custom event to a specific user's room.
@@ -432,37 +677,29 @@ var BaseService = class {
432
677
  * ```
433
678
  */
434
679
  emitToUserRoom(userId, eventName, data) {
435
- this.emitToRoom(`user:${userId}`, eventName, data);
436
- }
437
- /**
438
- * Get any active socket for room-based operations.
439
- */
440
- getAnySubscriberSocket() {
441
- for (const sockets of this.subscribers.values()) {
442
- const first = sockets.values().next();
443
- if (!first.done) {
444
- return first.value;
445
- }
446
- }
447
- return null;
680
+ this.emitToRoom(userRoom(userId), eventName, data);
448
681
  }
449
682
  /**
450
683
  * Emit an update to all subscribers of an entity.
451
- * Uses tier-based pre-filtering: computes filtered versions once,
452
- * then selects the appropriate version per subscriber.
684
+ *
685
+ * Room-based and therefore Redis-adapter-safe: elevated subscribers (in
686
+ * `{room}:full`, joined at subscribe time) receive the payload unfiltered;
687
+ * everyone else in the entity room receives it with protected fields
688
+ * stripped. Wire format and audience match the pre-4.0 per-socket path,
689
+ * but updates now cross nodes.
453
690
  *
454
691
  * Can be called from external method composition files.
455
692
  */
456
693
  emitUpdate(entryId, data) {
457
- const subs = this.subscribers.get(entryId);
458
- if (!subs || subs.size === 0) return;
459
- const eventName = `${this.serviceName}:update:${entryId}`;
460
- const fullData = data;
461
- const publicData = this.stripProtectedFields(data);
462
- for (const socket of subs) {
463
- const payload = this.hasElevatedAccess(socket, entryId) ? fullData : publicData;
464
- socket.emit(eventName, payload);
694
+ if (!this.io) {
695
+ this.logger.debug(`No io instance (service not registered); dropping update for ${entryId}`);
696
+ return;
465
697
  }
698
+ const eventName = `${this.serviceName}:update:${entryId}`;
699
+ const room = this.getRoomName(entryId);
700
+ const fullRoom = serviceFullRoom(this.serviceName, entryId);
701
+ this.io.to(fullRoom).emit(eventName, data);
702
+ this.io.to(room).except(fullRoom).emit(eventName, this.stripProtectedFields(data));
466
703
  }
467
704
  // ===========================================================================
468
705
  // Access Control
@@ -559,15 +796,24 @@ var BaseService = class {
559
796
  throw new Error("Insufficient permissions");
560
797
  }
561
798
  // ===========================================================================
562
- // Protected Fields Filtering
799
+ // DTO Mapping & Protected Fields Filtering
563
800
  // ===========================================================================
801
+ /**
802
+ * Map a raw row to the wire DTO. Default: identity.
803
+ * May fetch (e.g. Prisma includes). Used by the CRUD trio's auto-emission,
804
+ * by `subscribe`/`batchSubscribe` initial payloads, and as the default
805
+ * `toItem` for collections.
806
+ */
807
+ toDto(entity) {
808
+ return entity;
809
+ }
564
810
  /**
565
811
  * Get the list of fields that should be stripped for non-elevated subscribers.
566
812
  * Override in derived classes to customize which fields are protected.
567
813
  *
568
814
  * @example
569
815
  * ```typescript
570
- * protected override getProtectedFields(): (keyof User)[] {
816
+ * protected override getProtectedFields(): (keyof UserDto)[] {
571
817
  * return ['email', 'serviceAccess', 'discordId'];
572
818
  * }
573
819
  * ```
@@ -593,8 +839,8 @@ var BaseService = class {
593
839
  return socket.userId === entryId || this.hasServiceAccess(socket, "Admin");
594
840
  }
595
841
  /**
596
- * Strip protected fields from an entity.
597
- * Used internally by filterEntityForSubscriber.
842
+ * Strip protected fields from a DTO.
843
+ * Used internally by filterEntityForSubscriber and emitUpdate.
598
844
  */
599
845
  stripProtectedFields(entity) {
600
846
  const protectedFields = this.getProtectedFields();
@@ -605,16 +851,20 @@ var BaseService = class {
605
851
  return result;
606
852
  }
607
853
  /**
608
- * Filter entity data based on subscriber's access level.
854
+ * Filter DTO data based on subscriber's access level.
609
855
  * Override for complex filtering logic (e.g., multiple tiers).
610
856
  *
857
+ * Note: this shapes the *initial* subscribe payload only. Live emits are
858
+ * two-tier by room (`emitUpdate`) — full for elevated subscribers,
859
+ * protected-fields-stripped for everyone else.
860
+ *
611
861
  * @example
612
862
  * ```typescript
613
863
  * protected override filterEntityForSubscriber(
614
- * entity: Partial<TEntity>,
864
+ * entity: Partial<UserDto>,
615
865
  * socket: QuickdrawSocket,
616
866
  * entryId: string
617
- * ): Partial<TEntity> {
867
+ * ): Partial<UserDto> {
618
868
  * if (this.hasElevatedAccess(socket, entryId)) return entity;
619
869
  * if (this.isFriend(socket.userId, entryId)) return this.stripForFriends(entity);
620
870
  * return this.stripProtectedFields(entity);
@@ -625,6 +875,52 @@ var BaseService = class {
625
875
  return this.hasElevatedAccess(socket, entryId) ? entity : this.stripProtectedFields(entity);
626
876
  }
627
877
  // ===========================================================================
878
+ // Write Lifecycle Hooks
879
+ // ===========================================================================
880
+ // Overridable extension points around the CRUD trio — the home for
881
+ // cross-cutting side effects (audit logging, denormalized counters, search
882
+ // indexing) without overriding create/update/delete wholesale. Collections
883
+ // are notified automatically between emit and the after* hook.
884
+ //
885
+ // Semantics: before* hooks may veto by throwing (nothing is written);
886
+ // after* hooks run once the write has committed, so a throw propagates to
887
+ // the caller but cannot undo the write.
888
+ /** Transform or validate create input. Runs before the insert. */
889
+ async beforeCreate(data) {
890
+ return data;
891
+ }
892
+ /** Runs after a create has committed and been emitted. */
893
+ async afterCreate(_entity) {
894
+ }
895
+ /**
896
+ * Transform or validate update input. `before` is the pre-write row when
897
+ * the service has collections or overrides an update/delete hook
898
+ * (otherwise null — the extra fetch is skipped).
899
+ */
900
+ async beforeUpdate(_id, data, _before) {
901
+ return data;
902
+ }
903
+ /** Runs after an update has committed and been emitted. */
904
+ async afterUpdate(_before, _after) {
905
+ }
906
+ /** Runs before the delete. Throw to veto (delete() then returns false). */
907
+ async beforeDelete(_before) {
908
+ }
909
+ /** Runs after a delete has committed and been emitted. */
910
+ async afterDelete(_before) {
911
+ }
912
+ /**
913
+ * Whether writes need the pre-write row: true when the service has
914
+ * collections (scope diffing needs `before`) or overrides one of the
915
+ * update/delete hooks that receive it.
916
+ */
917
+ needsBeforeEntity() {
918
+ if (this.collections.size > 0) return true;
919
+ const base = _BaseService.prototype;
920
+ const self = this;
921
+ return self.beforeUpdate !== base.beforeUpdate || self.afterUpdate !== base.afterUpdate || self.beforeDelete !== base.beforeDelete || self.afterDelete !== base.afterDelete;
922
+ }
923
+ // ===========================================================================
628
924
  // CRUD Operations
629
925
  // ===========================================================================
630
926
  /**
@@ -636,44 +932,60 @@ var BaseService = class {
636
932
  });
637
933
  }
638
934
  /**
639
- * Create an entity and emit to subscribers.
935
+ * Create an entity, emit to subscribers, and notify collections.
640
936
  */
641
937
  async create(data) {
642
- const entity = await this.getDelegate().create({ data });
643
- this.emitUpdate(entity.id, entity);
938
+ const prepared = await this.beforeCreate(data);
939
+ const entity = await this.getDelegate().create({ data: prepared });
940
+ this.emitUpdate(entity.id, await this.toDto(entity));
941
+ await this.collections.notify({ type: "create", after: entity });
942
+ await this.afterCreate(entity);
644
943
  this.logger.info(`Created entity ${entity.id}`);
645
944
  return entity;
646
945
  }
647
946
  /**
648
- * Update an entity and emit to subscribers.
947
+ * Update an entity, emit to subscribers, and notify collections.
948
+ * Returns null when the write fails (e.g. the row does not exist).
649
949
  */
650
950
  async update(id, data) {
951
+ const before = this.needsBeforeEntity() ? await this.findById(id) : null;
952
+ const prepared = await this.beforeUpdate(id, data, before);
953
+ let entity;
651
954
  try {
652
- const entity = await this.getDelegate().update({
955
+ entity = await this.getDelegate().update({
653
956
  where: { id },
654
- data
957
+ data: prepared
655
958
  });
656
- this.emitUpdate(id, entity);
657
- this.logger.info(`Updated entity ${id}`);
658
- return entity;
659
959
  } catch {
660
960
  return null;
661
961
  }
962
+ this.emitUpdate(id, await this.toDto(entity));
963
+ await this.collections.notify({ type: "update", before, after: entity });
964
+ await this.afterUpdate(before, entity);
965
+ this.logger.info(`Updated entity ${id}`);
966
+ return entity;
662
967
  }
663
968
  /**
664
- * Delete an entity and emit deletion event to subscribers.
969
+ * Delete an entity, emit the deletion event, and notify collections.
970
+ * Returns false when the write fails or a beforeDelete hook throws.
665
971
  */
666
972
  async delete(id) {
973
+ const before = this.needsBeforeEntity() ? await this.findById(id) : null;
667
974
  try {
975
+ if (before) await this.beforeDelete(before);
668
976
  await this.getDelegate().delete({
669
977
  where: { id }
670
978
  });
671
- this.emitUpdate(id, { id, deleted: true });
672
- this.logger.info(`Deleted entity ${id}`);
673
- return true;
674
979
  } catch {
675
980
  return false;
676
981
  }
982
+ this.emitUpdate(id, { id, deleted: true });
983
+ if (before) {
984
+ await this.collections.notify({ type: "delete", before });
985
+ await this.afterDelete(before);
986
+ }
987
+ this.logger.info(`Deleted entity ${id}`);
988
+ return true;
677
989
  }
678
990
  // ===========================================================================
679
991
  // Public Method Definition
@@ -803,6 +1115,109 @@ var BaseService = class {
803
1115
  }
804
1116
  }
805
1117
  // ===========================================================================
1118
+ // Collections (scope-keyed list subscriptions)
1119
+ // ===========================================================================
1120
+ /**
1121
+ * Declare a collection: rows of this service grouped by a scope id derived
1122
+ * from the row. Declared in the constructor next to defineMethod /
1123
+ * defineChannel; discovered by ServiceRegistry the same way.
1124
+ *
1125
+ * Once declared, the CRUD trio emits `added`/`updated`/`removed` deltas to
1126
+ * the scope rooms automatically — including scope moves (a row whose scope
1127
+ * changes is removed-from-old + added-to-new) and predicate entry/exit
1128
+ * (`resolveScopeId` returning null). Hand-rolled write paths use the
1129
+ * emitCollection* choke points instead.
1130
+ *
1131
+ * @example
1132
+ * ```typescript
1133
+ * type TaskCollections = { cardsByProject: { item: TaskCardDTO } };
1134
+ *
1135
+ * this.defineCollection("cardsByProject", {
1136
+ * resolveScopeId: (task) => task.projectId,
1137
+ * checkScopeAccess: (userId, projectId) => this.isProjectMember(userId, projectId),
1138
+ * snapshot: (projectId, { cursor, limit }) => this.getCardPage(projectId, cursor, limit),
1139
+ * toItem: (task) => this.buildCardDTO(task.id),
1140
+ * });
1141
+ * ```
1142
+ */
1143
+ defineCollection(name, config) {
1144
+ this.collections.define(name, config);
1145
+ }
1146
+ /**
1147
+ * Get all collection definitions for registry discovery.
1148
+ */
1149
+ getCollections() {
1150
+ return this.collections.getAll();
1151
+ }
1152
+ /**
1153
+ * Handle a `{service}:collection:subscribe` (called by ServiceRegistry).
1154
+ * Cursor-less calls join the scope room and return the first page (with
1155
+ * `ids` when the snapshot provides them); cursor-bearing calls are pure
1156
+ * paging. Returns null on access denial or unknown collection.
1157
+ */
1158
+ async subscribeCollection(payload, socket) {
1159
+ return await this.collections.subscribe(payload, socket);
1160
+ }
1161
+ /**
1162
+ * Handle a `{service}:collection:unsubscribe` (called by ServiceRegistry).
1163
+ */
1164
+ unsubscribeCollection(payload, socket) {
1165
+ this.collections.unsubscribe(payload, socket);
1166
+ }
1167
+ /**
1168
+ * Notify all collections of a write this service performed outside the
1169
+ * CRUD trio, with full scope-move diffing. Prefer the emitCollection*
1170
+ * choke points when you already know the delta shape.
1171
+ */
1172
+ async notifyCollections(event) {
1173
+ await this.collections.notify(event);
1174
+ }
1175
+ /**
1176
+ * Emit an `updated` delta for an item to a scope room (client upserts —
1177
+ * added-vs-updated is cosmetic). The choke point for hand-rolled write
1178
+ * paths that already hold the item DTO.
1179
+ *
1180
+ * Can be called from external method composition files.
1181
+ */
1182
+ emitCollectionUpsert(collection, scopeId, item) {
1183
+ this.collections.emitUpsert(collection, scopeId, item);
1184
+ }
1185
+ /**
1186
+ * Emit a `removed` delta (id-only) to a scope room.
1187
+ *
1188
+ * Can be called from external method composition files.
1189
+ */
1190
+ emitCollectionRemove(collection, scopeId, id) {
1191
+ this.collections.emitRemove(collection, scopeId, id);
1192
+ }
1193
+ /**
1194
+ * Emit a scope move: `removed` from the old scope + `added` to the new.
1195
+ *
1196
+ * Can be called from external method composition files.
1197
+ */
1198
+ emitCollectionMove(collection, fromScopeId, toScopeId, item) {
1199
+ this.collections.emitMove(collection, fromScopeId, toScopeId, item);
1200
+ }
1201
+ /**
1202
+ * Tell subscribers to re-snapshot the scope (debounced client-side) — the
1203
+ * honest fallback for bulk operations and mass reorders.
1204
+ *
1205
+ * Can be called from external method composition files.
1206
+ */
1207
+ emitCollectionReset(collection, scopeId) {
1208
+ this.collections.emitReset(collection, scopeId);
1209
+ }
1210
+ /**
1211
+ * Force sockets out of a collection scope room (adapter-safe ACL
1212
+ * revocation). With `userId`, only that user's sockets; without, everyone.
1213
+ * Promise-shaped for forward compatibility with adapters that resolve
1214
+ * membership asynchronously.
1215
+ */
1216
+ kickFromCollection(collection, scopeId, userId) {
1217
+ this.collections.kickFromCollection(collection, scopeId, userId);
1218
+ return Promise.resolve();
1219
+ }
1220
+ // ===========================================================================
806
1221
  // Admin Methods
807
1222
  // ===========================================================================
808
1223
  /**
@@ -1030,7 +1445,7 @@ var BaseService = class {
1030
1445
  where: { id: entryId },
1031
1446
  data: { acl }
1032
1447
  });
1033
- this.emitUpdate(entryId, entity);
1448
+ this.emitUpdate(entryId, await this.toDto(entity));
1034
1449
  this.logger.info(`Updated ACL for entity ${entryId}`);
1035
1450
  return entity;
1036
1451
  } catch (error) {
@@ -1069,23 +1484,25 @@ var BaseService = class {
1069
1484
  }
1070
1485
  const subs = this.subscribers.get(entryId);
1071
1486
  const count = subs?.size ?? 0;
1072
- this.emitUpdate(entryId, entity);
1487
+ this.emitUpdate(entryId, await this.toDto(entity));
1073
1488
  this.logger.info(`Re-emitted entity ${entryId} to ${count} subscribers`);
1074
1489
  return { success: true, subscriberCount: count };
1075
1490
  }
1076
1491
  /**
1077
1492
  * Admin method: Unsubscribe all sockets from an entity.
1493
+ * Room-based (adapter-safe): clears the entity's rooms across all nodes.
1494
+ * The reported count covers this node's introspection map only.
1078
1495
  */
1079
1496
  adminUnsubscribeAll(entryId) {
1080
1497
  const subs = this.subscribers.get(entryId);
1081
1498
  const count = subs?.size ?? 0;
1082
- if (subs) {
1083
- const eventName = `${this.serviceName}:unsubscribed:${entryId}`;
1084
- for (const socket of subs) {
1085
- socket.emit(eventName, { reason: "admin_action" });
1086
- }
1087
- this.subscribers.delete(entryId);
1088
- }
1499
+ const room = this.getRoomName(entryId);
1500
+ const fullRoom = serviceFullRoom(this.serviceName, entryId);
1501
+ this.emitToRoom(room, `${this.serviceName}:unsubscribed:${entryId}`, {
1502
+ reason: "admin_action"
1503
+ });
1504
+ this.io?.in(room).socketsLeave([room, fullRoom]);
1505
+ this.subscribers.delete(entryId);
1089
1506
  this.logger.info(`Unsubscribed ${count} sockets from entity ${entryId}`);
1090
1507
  return { success: true, unsubscribedCount: count };
1091
1508
  }
@@ -1100,6 +1517,18 @@ var BaseService = class {
1100
1517
  }
1101
1518
  };
1102
1519
 
1520
+ // src/server/BaseRpcService.ts
1521
+ var BaseRpcService = class extends BaseService {
1522
+ /** RPC services have no entities to subscribe to. */
1523
+ async subscribe() {
1524
+ return null;
1525
+ }
1526
+ /** RPC services have no entities to subscribe to. */
1527
+ async batchSubscribe() {
1528
+ return {};
1529
+ }
1530
+ };
1531
+
1103
1532
  // src/server/ServiceRegistry.ts
1104
1533
  var CHANNEL_ABUSE_WINDOW_MS = 1e4;
1105
1534
  var CHANNEL_ABUSE_MULTIPLIER = 100;
@@ -1155,6 +1584,7 @@ var ServiceRegistry = class {
1155
1584
  this.registerSubscriptionListener(socket, `${serviceName}:subscribe`, service);
1156
1585
  this.registerBatchSubscriptionListener(socket, `${serviceName}:batchSubscribe`, service);
1157
1586
  this.registerUnsubscriptionListener(socket, `${serviceName}:unsubscribe`, service);
1587
+ this.registerCollectionListeners(socket, serviceName, service);
1158
1588
  }
1159
1589
  }
1160
1590
  /**
@@ -1480,6 +1910,72 @@ var ServiceRegistry = class {
1480
1910
  }
1481
1911
  );
1482
1912
  }
1913
+ /**
1914
+ * Register collection subscribe/unsubscribe listeners for a service.
1915
+ *
1916
+ * `{service}:collection:subscribe` doubles as the paging call: a
1917
+ * cursor-bearing payload skips the room join and just returns a page.
1918
+ */
1919
+ registerCollectionListeners(socket, serviceName, service) {
1920
+ socket.on(
1921
+ `${serviceName}:collection:subscribe`,
1922
+ async (payload, callback) => {
1923
+ try {
1924
+ if (!socket.userId) {
1925
+ callback?.({ success: false, error: "Authentication required", code: 401 });
1926
+ return;
1927
+ }
1928
+ if (!isValidCollectionPayload(payload) || !isValidSubscribeExtras(payload)) {
1929
+ callback?.({ success: false, error: "Invalid collection payload", code: 400 });
1930
+ return;
1931
+ }
1932
+ if (!service.subscribeCollection) {
1933
+ callback?.({ success: false, error: "Service has no collections", code: 404 });
1934
+ return;
1935
+ }
1936
+ const data = await service.subscribeCollection(payload, socket);
1937
+ if (data === null) {
1938
+ callback?.({
1939
+ success: false,
1940
+ error: "Access denied or unknown collection",
1941
+ code: 403
1942
+ });
1943
+ return;
1944
+ }
1945
+ callback?.({ success: true, data });
1946
+ } catch (error) {
1947
+ this.logger.error(`Error in collection subscription ${serviceName}:`, {
1948
+ error: error instanceof Error ? error.message : "Unknown error"
1949
+ });
1950
+ callback?.({
1951
+ success: false,
1952
+ error: error instanceof Error ? error.message : "Collection subscription failed"
1953
+ });
1954
+ }
1955
+ }
1956
+ );
1957
+ socket.on(
1958
+ `${serviceName}:collection:unsubscribe`,
1959
+ (payload, callback) => {
1960
+ try {
1961
+ if (!isValidCollectionPayload(payload)) {
1962
+ callback?.({ success: false, error: "Invalid collection payload", code: 400 });
1963
+ return;
1964
+ }
1965
+ service.unsubscribeCollection?.(payload, socket);
1966
+ callback?.({ success: true, data: { unsubscribed: true } });
1967
+ } catch (error) {
1968
+ this.logger.error(`Error in collection unsubscription ${serviceName}:`, {
1969
+ error: error instanceof Error ? error.message : "Unknown error"
1970
+ });
1971
+ callback?.({
1972
+ success: false,
1973
+ error: error instanceof Error ? error.message : "Collection unsubscription failed"
1974
+ });
1975
+ }
1976
+ }
1977
+ );
1978
+ }
1483
1979
  /**
1484
1980
  * Get list of registered service names.
1485
1981
  */
@@ -1493,6 +1989,16 @@ var ServiceRegistry = class {
1493
1989
  return Array.from(this.services.values());
1494
1990
  }
1495
1991
  };
1992
+ function isValidCollectionPayload(payload) {
1993
+ if (!payload || typeof payload !== "object") return false;
1994
+ const p = payload;
1995
+ return typeof p.collection === "string" && typeof p.scopeId === "string";
1996
+ }
1997
+ function isValidSubscribeExtras(payload) {
1998
+ const cursorOk = payload.cursor === void 0 || payload.cursor === null || typeof payload.cursor === "string";
1999
+ const limitOk = payload.limit === void 0 || typeof payload.limit === "number" && payload.limit > 0;
2000
+ return cursorOk && limitOk;
2001
+ }
1496
2002
  function createQuickdrawServer(options) {
1497
2003
  const logger = options.logger ?? consoleLogger;
1498
2004
  const serverLogger = logger.child({ service: "QuickdrawServer" });
@@ -1518,9 +2024,16 @@ function createQuickdrawServer(options) {
1518
2024
  try {
1519
2025
  if (options.auth?.authenticate) {
1520
2026
  const auth = socket.handshake.auth;
1521
- const userId = await options.auth.authenticate(quickdrawSocket, auth);
1522
- quickdrawSocket.userId = userId;
1523
- quickdrawSocket.serviceAccess = {};
2027
+ const result = await options.auth.authenticate(quickdrawSocket, auth);
2028
+ const identity = typeof result === "string" ? { userId: result } : result;
2029
+ quickdrawSocket.userId = identity?.userId;
2030
+ quickdrawSocket.principalType = identity?.principalType;
2031
+ quickdrawSocket.claims = identity?.claims;
2032
+ let serviceAccess = identity?.serviceAccess;
2033
+ if (!serviceAccess && identity?.userId && options.auth.loadServiceAccess) {
2034
+ serviceAccess = await options.auth.loadServiceAccess(identity.userId) ?? void 0;
2035
+ }
2036
+ quickdrawSocket.serviceAccess = serviceAccess ?? {};
1524
2037
  }
1525
2038
  next();
1526
2039
  } catch (error) {
@@ -1540,6 +2053,14 @@ function createQuickdrawServer(options) {
1540
2053
  socketId: quickdrawSocket.id,
1541
2054
  userId: quickdrawSocket.userId
1542
2055
  });
2056
+ if (quickdrawSocket.userId) {
2057
+ void quickdrawSocket.join(userRoom(quickdrawSocket.userId));
2058
+ }
2059
+ quickdrawSocket.emit("auth:info", {
2060
+ userId: quickdrawSocket.userId ?? null,
2061
+ serviceAccess: quickdrawSocket.serviceAccess ?? {},
2062
+ principalType: quickdrawSocket.principalType
2063
+ });
1543
2064
  quickdrawSocket.on("disconnect", () => {
1544
2065
  serverLogger.info("Socket disconnected", {
1545
2066
  socketId: quickdrawSocket.id,
@@ -2946,6 +3467,6 @@ Auto-generated documentation for all services.
2946
3467
  );
2947
3468
  }
2948
3469
 
2949
- export { BaseService, DEFAULT_MOCK_PATH_PREFIX, McpRegistry, OAUTH_RETURN_ORIGIN_COOKIE, SESSION_COOKIE, ServiceRegistry, applyRateLimitMiddleware, bootstrapMcpServer, checkEnv, clearSessionCookie, createJWT, createMcpRoutes, createMcpStdioServer, createMockOAuthProvider, createOAuthURL, createQuickdrawServer, createRateLimiter, createRequireAuth, createTieredRateLimiter, decrypt, decryptIfEncrypted, discordProvider, encrypt, exchangeOAuthCode, extractBearerOrCookieToken, generateToolMetadata, getDefaultEntityFields, getDiscordAvatarUrl, googleProvider, isEncrypted, isMockOAuthEnabled, isRedisAdapterAvailable, mergeWithDefaultFields, registerMockOAuthProvider, requireEnv, setSessionCookie, setupRedisAdapter, tryDecrypt, validateEnv, validateRedirectOrigin, verifyJWT, zodToAdminFields };
3470
+ export { BaseRpcService, BaseService, CollectionManager, DEFAULT_MOCK_PATH_PREFIX, McpRegistry, OAUTH_RETURN_ORIGIN_COOKIE, SESSION_COOKIE, ServiceRegistry, applyRateLimitMiddleware, bootstrapMcpServer, checkEnv, clearSessionCookie, createJWT, createMcpRoutes, createMcpStdioServer, createMockOAuthProvider, createOAuthURL, createQuickdrawServer, createRateLimiter, createRequireAuth, createTieredRateLimiter, decrypt, decryptIfEncrypted, discordProvider, encrypt, exchangeOAuthCode, extractBearerOrCookieToken, generateToolMetadata, getDefaultEntityFields, getDiscordAvatarUrl, googleProvider, isEncrypted, isMockOAuthEnabled, isRedisAdapterAvailable, mergeWithDefaultFields, registerMockOAuthProvider, requireEnv, setSessionCookie, setupRedisAdapter, tryDecrypt, validateEnv, validateRedirectOrigin, verifyJWT, zodToAdminFields };
2950
3471
  //# sourceMappingURL=index.js.map
2951
3472
  //# sourceMappingURL=index.js.map