@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.
@@ -1,10 +1,158 @@
1
- import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions, A as AdminListPayload, c as AdminListResponse, d as AdminSetACLPayload, e as AdminSubscribersResponse, S as ServiceRegistryInstance, B as BaseServiceInstance, f as QuickdrawServerOptions, Q as QuickdrawServerResult } from '../types-ClgSr90l.js';
1
+ import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions, S as ServiceRegistryInstance, B as BaseServiceInstance, c as QuickdrawServerOptions, Q as QuickdrawServerResult } from '../types-87lWXyNP.js';
2
+ export { d as QuickdrawIdentity } from '../types-87lWXyNP.js';
2
3
  import { Server, Socket } from 'socket.io';
3
4
  import { z } from 'zod';
4
- import { ACL, Logger, AccessLevel, ServiceMethodContext, ServiceMethodDefinition, ServiceChannelContext, ServiceChannelDefinition, AdminServiceMeta, AdminFieldConfig } from '../shared/index.js';
5
+ import { CollectionSnapshotPage, Logger, CollectionSubscribePayload, CollectionSnapshotResponse, CollectionUnsubscribePayload, ACL, AccessLevel, QuickdrawEventName, QuickdrawEventData, ServiceMethodContext, ServiceMethodDefinition, ServiceChannelContext, ServiceChannelDefinition, AdminServiceMeta, AdminListPayload, AdminListResponse, AdminSetACLPayload, AdminSubscribersResponse, AdminFieldConfig } from '../shared/index.js';
5
6
  import { Router } from 'express';
6
7
  import 'http';
7
8
 
9
+ /**
10
+ * A scope-keyed collection of a service's rows.
11
+ *
12
+ * A collection is "rows of this service, grouped by a scope id derived from
13
+ * the row" (`task.projectId`, `message.chatId`, `chatMember.userId`).
14
+ * Membership is a pure function of the row — that is what makes automatic
15
+ * add/remove/update emission, room-based multi-node fanout, and one-line ACL
16
+ * zero-thought.
17
+ *
18
+ * **ACL model (deliberate simplification):** a collection item is
19
+ * *scope-visible* — anyone who passes `checkScopeAccess` sees every item in
20
+ * full. There is no per-subscriber field tiering inside a collection; if a
21
+ * field is sensitive, strip it in `toItem`/`snapshot`. If visibility varies
22
+ * per user within a scope, that is not a collection — model it as separate
23
+ * scopes or per-entity subscriptions.
24
+ */
25
+ interface CollectionDefinition<TEntity, TItem extends {
26
+ id: string;
27
+ }> {
28
+ /**
29
+ * Which scope(s) a row belongs to. `null` = not in this collection
30
+ * (static predicate filtering). An array supports fan-out scopes
31
+ * (e.g. a chat appearing in every member's "myChats").
32
+ */
33
+ resolveScopeId: (entity: TEntity) => string | string[] | null | Promise<string | string[] | null>;
34
+ /** ACL for joining the scope room. Runs once at subscribe time. */
35
+ checkScopeAccess: (userId: string, scopeId: string, socket: QuickdrawSocket) => boolean | Promise<boolean>;
36
+ /** Initial page + re-snapshot query. Server-ordered. */
37
+ snapshot: (scopeId: string, opts: {
38
+ cursor: string | null;
39
+ limit: number;
40
+ userId: string;
41
+ }) => Promise<CollectionSnapshotPage<TItem>>;
42
+ /**
43
+ * Row -> collection item DTO for automatic delta emission.
44
+ * May be async (fetch with includes). Default: the service's `toDto`.
45
+ */
46
+ toItem?: (entity: TEntity) => TItem | Promise<TItem>;
47
+ /** Default page size for snapshot requests. Default 100, max clamp 500. */
48
+ defaultLimit?: number;
49
+ /**
50
+ * Revision source for `added`/`updated` deltas, instead of `Date.now()`
51
+ * at emit. Must return epoch-milliseconds-comparable numbers (e.g. the
52
+ * row's `updatedAt`). Use when multi-node clock skew bites.
53
+ */
54
+ revOf?: (item: TItem) => number;
55
+ }
56
+ /**
57
+ * A write observed by the CRUD trio, fed to {@link CollectionManager.notify}
58
+ * for automatic delta emission.
59
+ */
60
+ type CollectionWriteEvent<TEntity> = {
61
+ type: "create";
62
+ after: TEntity;
63
+ } | {
64
+ type: "update";
65
+ before: TEntity | null;
66
+ after: TEntity;
67
+ } | {
68
+ type: "delete";
69
+ before: TEntity;
70
+ };
71
+ interface CollectionManagerOptions<TEntity> {
72
+ serviceName: string;
73
+ getIo: () => Server | null;
74
+ logger: Logger;
75
+ /** Fallback item mapper when a definition has no `toItem` (the service's `toDto`). */
76
+ defaultToItem: (entity: TEntity) => {
77
+ id: string;
78
+ } | Promise<{
79
+ id: string;
80
+ }>;
81
+ }
82
+ /**
83
+ * Per-service collection state and emission logic: holds definitions, serves
84
+ * snapshot subscriptions, and turns write events into scope-room deltas.
85
+ *
86
+ * All emission is room-based (`io.to(room).emit`) — Redis-adapter-safe by
87
+ * construction. No collection state lives in any in-process subscriber map.
88
+ */
89
+ declare class CollectionManager<TEntity extends {
90
+ id: string;
91
+ }> {
92
+ private readonly definitions;
93
+ private readonly serviceName;
94
+ private readonly getIo;
95
+ private readonly logger;
96
+ private readonly defaultToItem;
97
+ constructor(options: CollectionManagerOptions<TEntity>);
98
+ define(name: string, config: CollectionDefinition<TEntity, {
99
+ id: string;
100
+ }>): void;
101
+ getAll(): Map<string, CollectionDefinition<TEntity, {
102
+ id: string;
103
+ }>>;
104
+ get size(): number;
105
+ /**
106
+ * Handle a `{service}:collection:subscribe`.
107
+ *
108
+ * Cursor-less: ACL check, join the scope room, snapshot with `ids`.
109
+ * Cursor-bearing: pure paging — ACL check, no room join, `ids` stripped
110
+ * (pruning against a partial page would drop valid cached rows).
111
+ *
112
+ * Returns `null` on access denial or unknown collection (indistinguishable
113
+ * to the client, mirroring entity subscribe semantics).
114
+ */
115
+ subscribe(payload: CollectionSubscribePayload, socket: QuickdrawSocket): Promise<CollectionSnapshotResponse<{
116
+ id: string;
117
+ }> | null>;
118
+ /** Handle a `{service}:collection:unsubscribe` — leave the scope room. */
119
+ unsubscribe(payload: CollectionUnsubscribePayload, socket: QuickdrawSocket): void;
120
+ /**
121
+ * Turn a write event into deltas for every collection on the service.
122
+ *
123
+ * - create: `added` to each scope of `after`.
124
+ * - update: diff scopes of `before` vs `after` — in both → `updated`,
125
+ * `after` only → `added`, `before` only → `removed`. Scope moves and
126
+ * predicate entry/exit fall out for free.
127
+ * - delete: `removed` to each scope of `before`.
128
+ *
129
+ * Errors in app-provided callbacks are logged, never thrown — the write
130
+ * has already committed; emission failures must not fail the response.
131
+ */
132
+ notify(event: CollectionWriteEvent<TEntity>): Promise<void>;
133
+ private notifyOne;
134
+ /** Emit `updated` (client upserts; added-vs-updated is cosmetic). */
135
+ emitUpsert(collection: string, scopeId: string, item: {
136
+ id: string;
137
+ }): void;
138
+ emitRemove(collection: string, scopeId: string, id: string): void;
139
+ /** A move is removed-from-old + added-to-new. */
140
+ emitMove(collection: string, fromScopeId: string, toScopeId: string, item: {
141
+ id: string;
142
+ }): void;
143
+ /** Clients re-snapshot (debounced) — for bulk ops and mass reorders. */
144
+ emitReset(collection: string, scopeId: string): void;
145
+ /**
146
+ * Force sockets out of a scope room — ACL revocation that works across
147
+ * nodes (`socketsLeave` is adapter-safe). With `userId`, only that user's
148
+ * sockets are kicked; without, the whole room is cleared.
149
+ */
150
+ kickFromCollection(collection: string, scopeId: string, userId?: string): void;
151
+ private toItem;
152
+ private revFor;
153
+ private emitDelta;
154
+ }
155
+
8
156
  /**
9
157
  * Base class for all quickdraw services.
10
158
  *
@@ -20,6 +168,8 @@ import 'http';
20
168
  * @typeParam TUpdateInput - The update input type
21
169
  * @typeParam TServiceMethods - Service method definitions map
22
170
  * @typeParam TChannels - Service channel payload map (high-frequency fire-and-forget events)
171
+ * @typeParam TDto - The wire shape emitted to subscribers (defaults to TEntity; see `toDto`)
172
+ * @typeParam TCollections - Collection map: name -> { item } (see `defineCollection`)
23
173
  *
24
174
  * @example
25
175
  * ```typescript
@@ -51,7 +201,19 @@ declare abstract class BaseService<TEntity extends {
51
201
  response: unknown;
52
202
  }>, TChannels extends {
53
203
  [K in keyof TChannels]: unknown;
54
- } = Record<string, unknown>> {
204
+ } = Record<string, unknown>, TDto extends {
205
+ id: string;
206
+ } = TEntity, TCollections extends {
207
+ [K in keyof TCollections]: {
208
+ item: {
209
+ id: string;
210
+ };
211
+ };
212
+ } = Record<string, {
213
+ item: {
214
+ id: string;
215
+ };
216
+ }>> {
55
217
  readonly serviceName: string;
56
218
  protected readonly hasEntryACL: boolean;
57
219
  protected readonly defaultACL: ACL;
@@ -61,6 +223,7 @@ declare abstract class BaseService<TEntity extends {
61
223
  protected io: Server | null;
62
224
  private readonly publicMethods;
63
225
  private readonly publicChannels;
226
+ private readonly collections;
64
227
  private adminMeta;
65
228
  constructor(options: BaseServiceOptions);
66
229
  /**
@@ -80,14 +243,20 @@ declare abstract class BaseService<TEntity extends {
80
243
  /**
81
244
  * Get the Socket.io room name for an entry.
82
245
  * Used for room-based broadcasting across services.
246
+ * Static counterpart: `serviceRoom(serviceName, entryId)` from the root export.
83
247
  */
84
248
  getRoomName(entryId: string): string;
85
249
  /**
86
250
  * Subscribe a socket to an entity's updates.
87
- * Returns the current entity data if access is granted, null otherwise.
88
- * Also joins the socket to the entity's Socket.io room for cross-service events.
251
+ * Returns the current entity data (as the wire DTO, filtered for the
252
+ * subscriber's tier) if access is granted, null otherwise.
253
+ *
254
+ * Joins the entity's Socket.io room; subscribers with elevated access
255
+ * additionally join `{room}:full` so `emitUpdate` can address the two
256
+ * tiers as rooms (adapter-safe). The tier is fixed here, at subscribe
257
+ * time — an access change takes effect on re-subscribe.
89
258
  */
90
- subscribe(entryId: string, socket: QuickdrawSocket, requiredLevel?: AccessLevel): Promise<TEntity | null>;
259
+ subscribe(entryId: string, socket: QuickdrawSocket, requiredLevel?: AccessLevel): Promise<Partial<TDto> | null>;
91
260
  /**
92
261
  * Subscribe a socket to multiple entities at once.
93
262
  * Batches ACL checks and entity fetches for efficiency.
@@ -96,7 +265,7 @@ declare abstract class BaseService<TEntity extends {
96
265
  *
97
266
  * Default implementation runs individual checks/fetches in parallel via Promise.all.
98
267
  */
99
- batchSubscribe(entryIds: string[], socket: QuickdrawSocket, requiredLevel?: AccessLevel): Promise<Record<string, TEntity | null>>;
268
+ batchSubscribe(entryIds: string[], socket: QuickdrawSocket, requiredLevel?: AccessLevel): Promise<Record<string, Partial<TDto> | null>>;
100
269
  /**
101
270
  * Batch ACL check for multiple entities.
102
271
  * Override in derived classes for efficient batch queries
@@ -131,17 +300,21 @@ declare abstract class BaseService<TEntity extends {
131
300
  * @param eventName - The event name to emit
132
301
  * @param data - The data to emit
133
302
  *
303
+ * Event names and payloads are typed via the augmentable
304
+ * `QuickdrawEventMap` — with no augmentation this degrades to
305
+ * `string`/`unknown` exactly as before.
306
+ *
134
307
  * @example
135
308
  * ```typescript
136
309
  * // In MessageService, notify chat subscribers of a new message
137
310
  * this.emitToRoom(
138
- * `chatService:${message.chatId}`,
311
+ * serviceRoom('chatService', message.chatId),
139
312
  * 'chat:message',
140
313
  * messageDTO
141
314
  * );
142
315
  * ```
143
316
  */
144
- emitToRoom(roomName: string, eventName: string, data: unknown): void;
317
+ emitToRoom<E extends QuickdrawEventName>(roomName: string, eventName: E, data: QuickdrawEventData<E & string>): void;
145
318
  /**
146
319
  * Emit a custom event to a room using Socket.io's volatile flag.
147
320
  *
@@ -163,7 +336,7 @@ declare abstract class BaseService<TEntity extends {
163
336
  * );
164
337
  * ```
165
338
  */
166
- emitToRoomVolatile(roomName: string, eventName: string, data: unknown): void;
339
+ emitToRoomVolatile<E extends QuickdrawEventName>(roomName: string, eventName: E, data: QuickdrawEventData<E & string>): void;
167
340
  /**
168
341
  * Emit a custom event to a specific user's room.
169
342
  * Users join `user:{userId}` on connection (set up in auth middleware).
@@ -176,19 +349,19 @@ declare abstract class BaseService<TEntity extends {
176
349
  * this.emitToUserRoom(userId, "budget:shared", { budgetId, sharedBy });
177
350
  * ```
178
351
  */
179
- emitToUserRoom(userId: string, eventName: string, data: unknown): void;
180
- /**
181
- * Get any active socket for room-based operations.
182
- */
183
- private getAnySubscriberSocket;
352
+ emitToUserRoom<E extends QuickdrawEventName>(userId: string, eventName: E, data: QuickdrawEventData<E & string>): void;
184
353
  /**
185
354
  * Emit an update to all subscribers of an entity.
186
- * Uses tier-based pre-filtering: computes filtered versions once,
187
- * then selects the appropriate version per subscriber.
355
+ *
356
+ * Room-based and therefore Redis-adapter-safe: elevated subscribers (in
357
+ * `{room}:full`, joined at subscribe time) receive the payload unfiltered;
358
+ * everyone else in the entity room receives it with protected fields
359
+ * stripped. Wire format and audience match the pre-4.0 per-socket path,
360
+ * but updates now cross nodes.
188
361
  *
189
362
  * Can be called from external method composition files.
190
363
  */
191
- emitUpdate(entryId: string, data: Partial<TEntity>): void;
364
+ emitUpdate(entryId: string, data: Partial<TDto>): void;
192
365
  /**
193
366
  * Check if a user has access to subscribe to an entity.
194
367
  * Override in derived classes for custom logic.
@@ -218,18 +391,25 @@ declare abstract class BaseService<TEntity extends {
218
391
  * Throws if access is denied.
219
392
  */
220
393
  ensureAccessForMethod(requiredLevel: AccessLevel, socket: QuickdrawSocket, entryId?: string): Promise<void>;
394
+ /**
395
+ * Map a raw row to the wire DTO. Default: identity.
396
+ * May fetch (e.g. Prisma includes). Used by the CRUD trio's auto-emission,
397
+ * by `subscribe`/`batchSubscribe` initial payloads, and as the default
398
+ * `toItem` for collections.
399
+ */
400
+ protected toDto(entity: TEntity): TDto | Promise<TDto>;
221
401
  /**
222
402
  * Get the list of fields that should be stripped for non-elevated subscribers.
223
403
  * Override in derived classes to customize which fields are protected.
224
404
  *
225
405
  * @example
226
406
  * ```typescript
227
- * protected override getProtectedFields(): (keyof User)[] {
407
+ * protected override getProtectedFields(): (keyof UserDto)[] {
228
408
  * return ['email', 'serviceAccess', 'discordId'];
229
409
  * }
230
410
  * ```
231
411
  */
232
- protected getProtectedFields(): (keyof TEntity)[];
412
+ protected getProtectedFields(): (keyof TDto)[];
233
413
  /**
234
414
  * Check if a socket has elevated access to an entity (receives full data).
235
415
  * Default: owner (socket.userId === entryId) or service-level Admin.
@@ -246,42 +426,70 @@ declare abstract class BaseService<TEntity extends {
246
426
  */
247
427
  protected hasElevatedAccess(socket: QuickdrawSocket, entryId: string): boolean;
248
428
  /**
249
- * Strip protected fields from an entity.
250
- * Used internally by filterEntityForSubscriber.
429
+ * Strip protected fields from a DTO.
430
+ * Used internally by filterEntityForSubscriber and emitUpdate.
251
431
  */
252
- protected stripProtectedFields<T extends Partial<TEntity>>(entity: T): T;
432
+ protected stripProtectedFields<T extends Partial<TDto>>(entity: T): T;
253
433
  /**
254
- * Filter entity data based on subscriber's access level.
434
+ * Filter DTO data based on subscriber's access level.
255
435
  * Override for complex filtering logic (e.g., multiple tiers).
256
436
  *
437
+ * Note: this shapes the *initial* subscribe payload only. Live emits are
438
+ * two-tier by room (`emitUpdate`) — full for elevated subscribers,
439
+ * protected-fields-stripped for everyone else.
440
+ *
257
441
  * @example
258
442
  * ```typescript
259
443
  * protected override filterEntityForSubscriber(
260
- * entity: Partial<TEntity>,
444
+ * entity: Partial<UserDto>,
261
445
  * socket: QuickdrawSocket,
262
446
  * entryId: string
263
- * ): Partial<TEntity> {
447
+ * ): Partial<UserDto> {
264
448
  * if (this.hasElevatedAccess(socket, entryId)) return entity;
265
449
  * if (this.isFriend(socket.userId, entryId)) return this.stripForFriends(entity);
266
450
  * return this.stripProtectedFields(entity);
267
451
  * }
268
452
  * ```
269
453
  */
270
- protected filterEntityForSubscriber(entity: Partial<TEntity>, socket: QuickdrawSocket, entryId: string): Partial<TEntity>;
454
+ protected filterEntityForSubscriber(entity: Partial<TDto>, socket: QuickdrawSocket, entryId: string): Partial<TDto>;
455
+ /** Transform or validate create input. Runs before the insert. */
456
+ protected beforeCreate(data: TCreateInput): Promise<TCreateInput>;
457
+ /** Runs after a create has committed and been emitted. */
458
+ protected afterCreate(_entity: TEntity): Promise<void>;
459
+ /**
460
+ * Transform or validate update input. `before` is the pre-write row when
461
+ * the service has collections or overrides an update/delete hook
462
+ * (otherwise null — the extra fetch is skipped).
463
+ */
464
+ protected beforeUpdate(_id: string, data: TUpdateInput, _before: TEntity | null): Promise<TUpdateInput>;
465
+ /** Runs after an update has committed and been emitted. */
466
+ protected afterUpdate(_before: TEntity | null, _after: TEntity): Promise<void>;
467
+ /** Runs before the delete. Throw to veto (delete() then returns false). */
468
+ protected beforeDelete(_before: TEntity): Promise<void>;
469
+ /** Runs after a delete has committed and been emitted. */
470
+ protected afterDelete(_before: TEntity): Promise<void>;
471
+ /**
472
+ * Whether writes need the pre-write row: true when the service has
473
+ * collections (scope diffing needs `before`) or overrides one of the
474
+ * update/delete hooks that receive it.
475
+ */
476
+ private needsBeforeEntity;
271
477
  /**
272
478
  * Find an entity by ID.
273
479
  */
274
480
  protected findById(id: string): Promise<TEntity | null>;
275
481
  /**
276
- * Create an entity and emit to subscribers.
482
+ * Create an entity, emit to subscribers, and notify collections.
277
483
  */
278
484
  protected create(data: TCreateInput): Promise<TEntity>;
279
485
  /**
280
- * Update an entity and emit to subscribers.
486
+ * Update an entity, emit to subscribers, and notify collections.
487
+ * Returns null when the write fails (e.g. the row does not exist).
281
488
  */
282
489
  protected update(id: string, data: TUpdateInput): Promise<TEntity | null>;
283
490
  /**
284
- * Delete an entity and emit deletion event to subscribers.
491
+ * Delete an entity, emit the deletion event, and notify collections.
492
+ * Returns false when the write fails or a beforeDelete hook throws.
285
493
  */
286
494
  protected delete(id: string): Promise<boolean>;
287
495
  /**
@@ -369,6 +577,99 @@ declare abstract class BaseService<TEntity extends {
369
577
  * ```
370
578
  */
371
579
  protected verifyAllMethods(expectedMethods: readonly (keyof TServiceMethods & string)[]): void;
580
+ /**
581
+ * Declare a collection: rows of this service grouped by a scope id derived
582
+ * from the row. Declared in the constructor next to defineMethod /
583
+ * defineChannel; discovered by ServiceRegistry the same way.
584
+ *
585
+ * Once declared, the CRUD trio emits `added`/`updated`/`removed` deltas to
586
+ * the scope rooms automatically — including scope moves (a row whose scope
587
+ * changes is removed-from-old + added-to-new) and predicate entry/exit
588
+ * (`resolveScopeId` returning null). Hand-rolled write paths use the
589
+ * emitCollection* choke points instead.
590
+ *
591
+ * @example
592
+ * ```typescript
593
+ * type TaskCollections = { cardsByProject: { item: TaskCardDTO } };
594
+ *
595
+ * this.defineCollection("cardsByProject", {
596
+ * resolveScopeId: (task) => task.projectId,
597
+ * checkScopeAccess: (userId, projectId) => this.isProjectMember(userId, projectId),
598
+ * snapshot: (projectId, { cursor, limit }) => this.getCardPage(projectId, cursor, limit),
599
+ * toItem: (task) => this.buildCardDTO(task.id),
600
+ * });
601
+ * ```
602
+ */
603
+ protected defineCollection<K extends keyof TCollections & string>(name: K, config: CollectionDefinition<TEntity, TCollections[K]["item"]>): void;
604
+ /**
605
+ * Get all collection definitions for registry discovery.
606
+ */
607
+ getCollections(): Map<string, CollectionDefinition<TEntity, {
608
+ id: string;
609
+ }>>;
610
+ /**
611
+ * Handle a `{service}:collection:subscribe` (called by ServiceRegistry).
612
+ * Cursor-less calls join the scope room and return the first page (with
613
+ * `ids` when the snapshot provides them); cursor-bearing calls are pure
614
+ * paging. Returns null on access denial or unknown collection.
615
+ */
616
+ subscribeCollection(payload: CollectionSubscribePayload, socket: QuickdrawSocket): Promise<CollectionSnapshotResponse<{
617
+ id: string;
618
+ }> | null>;
619
+ /**
620
+ * Handle a `{service}:collection:unsubscribe` (called by ServiceRegistry).
621
+ */
622
+ unsubscribeCollection(payload: CollectionUnsubscribePayload, socket: QuickdrawSocket): void;
623
+ /**
624
+ * Notify all collections of a write this service performed outside the
625
+ * CRUD trio, with full scope-move diffing. Prefer the emitCollection*
626
+ * choke points when you already know the delta shape.
627
+ */
628
+ protected notifyCollections(event: {
629
+ type: "create";
630
+ after: TEntity;
631
+ } | {
632
+ type: "update";
633
+ before: TEntity | null;
634
+ after: TEntity;
635
+ } | {
636
+ type: "delete";
637
+ before: TEntity;
638
+ }): Promise<void>;
639
+ /**
640
+ * Emit an `updated` delta for an item to a scope room (client upserts —
641
+ * added-vs-updated is cosmetic). The choke point for hand-rolled write
642
+ * paths that already hold the item DTO.
643
+ *
644
+ * Can be called from external method composition files.
645
+ */
646
+ emitCollectionUpsert<K extends keyof TCollections & string>(collection: K, scopeId: string, item: TCollections[K]["item"]): void;
647
+ /**
648
+ * Emit a `removed` delta (id-only) to a scope room.
649
+ *
650
+ * Can be called from external method composition files.
651
+ */
652
+ emitCollectionRemove<K extends keyof TCollections & string>(collection: K, scopeId: string, id: string): void;
653
+ /**
654
+ * Emit a scope move: `removed` from the old scope + `added` to the new.
655
+ *
656
+ * Can be called from external method composition files.
657
+ */
658
+ emitCollectionMove<K extends keyof TCollections & string>(collection: K, fromScopeId: string, toScopeId: string, item: TCollections[K]["item"]): void;
659
+ /**
660
+ * Tell subscribers to re-snapshot the scope (debounced client-side) — the
661
+ * honest fallback for bulk operations and mass reorders.
662
+ *
663
+ * Can be called from external method composition files.
664
+ */
665
+ emitCollectionReset<K extends keyof TCollections & string>(collection: K, scopeId: string): void;
666
+ /**
667
+ * Force sockets out of a collection scope room (adapter-safe ACL
668
+ * revocation). With `userId`, only that user's sockets; without, everyone.
669
+ * Promise-shaped for forward compatibility with adapters that resolve
670
+ * membership asynchronously.
671
+ */
672
+ kickFromCollection(collection: string, scopeId: string, userId?: string): Promise<void>;
372
673
  /**
373
674
  * Install standard admin CRUD methods.
374
675
  * Call this in derived class constructor to expose admin endpoints.
@@ -445,6 +746,8 @@ declare abstract class BaseService<TEntity extends {
445
746
  }>;
446
747
  /**
447
748
  * Admin method: Unsubscribe all sockets from an entity.
749
+ * Room-based (adapter-safe): clears the entity's rooms across all nodes.
750
+ * The reported count covers this node's introspection map only.
448
751
  */
449
752
  protected adminUnsubscribeAll(entryId: string): {
450
753
  success: boolean;
@@ -456,6 +759,49 @@ declare abstract class BaseService<TEntity extends {
456
759
  protected getDefaultDeniedFields(): string[];
457
760
  }
458
761
 
762
+ /**
763
+ * Base class for delegate-less, method-only services — methods, channels,
764
+ * ACL, and logging without a Prisma delegate, CRUD, or entity subscriptions.
765
+ *
766
+ * Use for services that orchestrate rather than own rows: build/deploy
767
+ * triggers, external API bridges, health/vibe checks. Replaces the
768
+ * `BaseService<never, never, never, TMethods>` contortion.
769
+ *
770
+ * Entity subscribe/batchSubscribe resolve to null/empty (there are no
771
+ * entities), and the inherited CRUD trio throws if reached (no delegate).
772
+ * Room emits (`emitToRoom`, `emitToUserRoom`, volatile) work as usual.
773
+ *
774
+ * @example
775
+ * ```typescript
776
+ * class CloudBuildService extends BaseRpcService<CloudBuildMethods> {
777
+ * constructor() {
778
+ * super({ serviceName: "cloudBuildService" });
779
+ * this.defineMethod("triggerBuild", "Moderate", async (payload, ctx) => {
780
+ * return await this.startBuild(payload.ref, ctx.userId);
781
+ * });
782
+ * }
783
+ * }
784
+ * ```
785
+ */
786
+ declare abstract class BaseRpcService<TServiceMethods extends {
787
+ [K in keyof TServiceMethods]: {
788
+ payload: unknown;
789
+ response: unknown;
790
+ };
791
+ } = Record<string, {
792
+ payload: unknown;
793
+ response: unknown;
794
+ }>, TChannels extends {
795
+ [K in keyof TChannels]: unknown;
796
+ } = Record<string, unknown>> extends BaseService<{
797
+ id: string;
798
+ }, Record<string, unknown>, Record<string, unknown>, TServiceMethods, TChannels> {
799
+ /** RPC services have no entities to subscribe to. */
800
+ subscribe(): Promise<null>;
801
+ /** RPC services have no entities to subscribe to. */
802
+ batchSubscribe(): Promise<Record<string, null>>;
803
+ }
804
+
459
805
  /**
460
806
  * Central registry for auto-discovering and registering service methods as Socket.io events.
461
807
  *
@@ -544,6 +890,13 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
544
890
  * Register unsubscription listener for a service.
545
891
  */
546
892
  private registerUnsubscriptionListener;
893
+ /**
894
+ * Register collection subscribe/unsubscribe listeners for a service.
895
+ *
896
+ * `{service}:collection:subscribe` doubles as the paging call: a
897
+ * cursor-bearing payload skips the room join and just returns a page.
898
+ */
899
+ private registerCollectionListeners;
547
900
  /**
548
901
  * Get list of registered service names.
549
902
  */
@@ -557,9 +910,20 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
557
910
  /**
558
911
  * Create a fully configured quickdraw server with one function call.
559
912
  *
913
+ * Authentication: `authenticate` may return a plain userId string or a
914
+ * structured {@link QuickdrawIdentity} (`{ userId?, principalType?, claims?,
915
+ * serviceAccess? }`) for non-trivial principal models (task tokens, runner
916
+ * tokens…). Service-level access comes from the identity's `serviceAccess`,
917
+ * or from `loadServiceAccess(userId)` when the identity omits it.
918
+ *
919
+ * On connection, authenticated sockets join `user:{userId}` (so
920
+ * `emitToUserRoom` and user-targeted kicks work out of the box) and every
921
+ * socket receives an `auth:info` event with
922
+ * `{ userId, serviceAccess, principalType }`.
923
+ *
560
924
  * @example
561
925
  * ```typescript
562
- * import { createQuickdrawServer } from '@quickdraw/core/server';
926
+ * import { createQuickdrawServer } from '@fitzzero/quickdraw-core/server';
563
927
  * import { ChatService, UserService } from './services';
564
928
  *
565
929
  * const { io, httpServer, registry } = createQuickdrawServer({
@@ -571,9 +935,13 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
571
935
  * },
572
936
  * auth: {
573
937
  * authenticate: async (socket, auth) => {
574
- * // Verify JWT and return userId
575
938
  * const payload = await verifyJWT(auth.token);
576
- * return payload.userId;
939
+ * if (!payload) return undefined;
940
+ * return { userId: payload.userId, principalType: "user" };
941
+ * },
942
+ * loadServiceAccess: async (userId) => {
943
+ * const user = await prisma.user.findUnique({ where: { id: userId } });
944
+ * return user?.serviceAccess as Record<string, AccessLevel> | null;
577
945
  * },
578
946
  * },
579
947
  * });
@@ -1431,4 +1799,4 @@ declare function createMcpRoutes(router: Router, options: McpHttpRoutesOptions):
1431
1799
  */
1432
1800
  declare function generateToolMetadata(options: GenerateToolMetadataOptions): void;
1433
1801
 
1434
- export { AdminListPayload, AdminListResponse, AdminSetACLPayload, AdminSubscribersResponse, type AuthRequest, type AuthResponse, BaseService, BaseServiceInstance, BaseServiceOptions, type CookieResponse, type CookieSettings, DEFAULT_MOCK_PATH_PREFIX, type DiscordUser, type EnvValidationResult, type GenerateToolMetadataOptions, type GoogleUser, InstallAdminMethodsOptions, type JWTPayload, type McpHttpRoutesOptions, ServiceMethodContext as McpMethodContext, ServiceMethodDefinition as McpMethodDefinition, McpRegistry, type McpRegistryInstance, type McpRegistryOptions, type McpServiceInstance, type McpStdioServerOptions, type McpToolDefinition, type MethodSpec, type MockOAuthProviderOptions, type MockOAuthRouter, type MockOAuthUser, OAUTH_RETURN_ORIGIN_COOKIE, type OAuthConfig, type OAuthProvider, type OAuthTokenResponse, PrismaDelegate, QuickdrawServerOptions, QuickdrawServerResult, QuickdrawSocket, type RateLimitOptions, type RateLimiter, type RedisAdapterOptions, type RedisAdapterResult, type RegisterMockOAuthOptions, type RequireAuthOptions, SESSION_COOKIE, type ServiceInfo, type ServiceMethodInfo, ServiceRegistry, ServiceRegistryInstance, type ServiceRegistryOptions, type ServiceToolSpec, type SessionCookieOptions, type ValidateEnvOptions, type ValidateOriginOptions, type ZodToAdminFieldsOptions, 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 };
1802
+ export { AdminListPayload, AdminListResponse, AdminSetACLPayload, AdminSubscribersResponse, type AuthRequest, type AuthResponse, BaseRpcService, BaseService, BaseServiceInstance, BaseServiceOptions, type CollectionDefinition, CollectionManager, type CollectionWriteEvent, type CookieResponse, type CookieSettings, DEFAULT_MOCK_PATH_PREFIX, type DiscordUser, type EnvValidationResult, type GenerateToolMetadataOptions, type GoogleUser, InstallAdminMethodsOptions, type JWTPayload, type McpHttpRoutesOptions, ServiceMethodContext as McpMethodContext, ServiceMethodDefinition as McpMethodDefinition, McpRegistry, type McpRegistryInstance, type McpRegistryOptions, type McpServiceInstance, type McpStdioServerOptions, type McpToolDefinition, type MethodSpec, type MockOAuthProviderOptions, type MockOAuthRouter, type MockOAuthUser, OAUTH_RETURN_ORIGIN_COOKIE, type OAuthConfig, type OAuthProvider, type OAuthTokenResponse, PrismaDelegate, QuickdrawServerOptions, QuickdrawServerResult, QuickdrawSocket, type RateLimitOptions, type RateLimiter, type RedisAdapterOptions, type RedisAdapterResult, type RegisterMockOAuthOptions, type RequireAuthOptions, SESSION_COOKIE, type ServiceInfo, type ServiceMethodInfo, ServiceRegistry, ServiceRegistryInstance, type ServiceRegistryOptions, type ServiceToolSpec, type SessionCookieOptions, type ValidateEnvOptions, type ValidateOriginOptions, type ZodToAdminFieldsOptions, 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 };