@fitzzero/quickdraw-core 3.9.1 → 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 +402 -34
- package/dist/server/index.js +610 -89
- 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.js
CHANGED
|
@@ -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
|
|
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
|
|
230
|
-
*
|
|
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
|
|
523
|
+
const dtoMap = /* @__PURE__ */ new Map();
|
|
277
524
|
for (const entity of entities) {
|
|
278
|
-
|
|
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
|
|
290
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
341
|
-
void socket.leave(
|
|
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
|
-
|
|
351
|
-
void socket.leave(
|
|
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
|
-
*
|
|
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.
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
390
|
-
this.logger.debug(`Emitted ${eventName} to room ${roomName}
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
452
|
-
*
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
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
|
|
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
|
|
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<
|
|
864
|
+
* entity: Partial<UserDto>,
|
|
615
865
|
* socket: QuickdrawSocket,
|
|
616
866
|
* entryId: string
|
|
617
|
-
* ): Partial<
|
|
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
|
|
935
|
+
* Create an entity, emit to subscribers, and notify collections.
|
|
640
936
|
*/
|
|
641
937
|
async create(data) {
|
|
642
|
-
const
|
|
643
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
|
1522
|
-
|
|
1523
|
-
quickdrawSocket.
|
|
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
|