@fitzzero/quickdraw-core 3.9.0 → 4.0.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.
- package/README.md +201 -25
- package/dist/client/index.d.ts +132 -6
- package/dist/client/index.js +492 -22
- package/dist/client/index.js.map +1 -1
- package/dist/client/testing.d.ts +1 -1
- package/dist/client/testing.js +3 -0
- package/dist/client/testing.js.map +1 -1
- package/dist/server/index.d.ts +403 -35
- package/dist/server/index.js +611 -90
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server/testing.js +98 -3
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +154 -31
- package/dist/shared/index.js +16 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/{types-ClgSr90l.d.ts → types-87lWXyNP.d.ts} +36 -32
- package/dist/{types-DUHPIy_5.d.ts → types-CX5LuC_8.d.ts} +94 -2
- package/eslint-plugin-quickdraw/index.mjs +4 -0
- package/eslint-plugin-quickdraw/rules/no-manual-collection-events.mjs +68 -0
- package/oxlint.base.jsonc +3 -0
- package/package.json +1 -1
package/dist/server/index.d.ts
CHANGED
|
@@ -1,10 +1,158 @@
|
|
|
1
|
-
import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions,
|
|
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 {
|
|
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
|
|
88
|
-
*
|
|
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<
|
|
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,
|
|
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
|
-
*
|
|
311
|
+
* serviceRoom('chatService', message.chatId),
|
|
139
312
|
* 'chat:message',
|
|
140
313
|
* messageDTO
|
|
141
314
|
* );
|
|
142
315
|
* ```
|
|
143
316
|
*/
|
|
144
|
-
emitToRoom(roomName: string, eventName:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
187
|
-
*
|
|
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<
|
|
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
|
|
407
|
+
* protected override getProtectedFields(): (keyof UserDto)[] {
|
|
228
408
|
* return ['email', 'serviceAccess', 'discordId'];
|
|
229
409
|
* }
|
|
230
410
|
* ```
|
|
231
411
|
*/
|
|
232
|
-
protected getProtectedFields(): (keyof
|
|
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
|
|
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<
|
|
432
|
+
protected stripProtectedFields<T extends Partial<TDto>>(entity: T): T;
|
|
253
433
|
/**
|
|
254
|
-
* Filter
|
|
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<
|
|
444
|
+
* entity: Partial<UserDto>,
|
|
261
445
|
* socket: QuickdrawSocket,
|
|
262
446
|
* entryId: string
|
|
263
|
-
* ): Partial<
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
* });
|
|
@@ -1092,7 +1460,7 @@ interface CookieResponse {
|
|
|
1092
1460
|
interface SessionCookieOptions {
|
|
1093
1461
|
/** Cookie name. Default: "session". */
|
|
1094
1462
|
cookieName?: string;
|
|
1095
|
-
/** Lifetime in ms. Default:
|
|
1463
|
+
/** Lifetime in ms. Default: 7 days (matches the default JWT expiry). */
|
|
1096
1464
|
maxAgeMs?: number;
|
|
1097
1465
|
/** Cookie domain. Defaults to process.env.COOKIE_DOMAIN. */
|
|
1098
1466
|
domain?: string;
|
|
@@ -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 };
|