@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/shared/index.d.ts
CHANGED
|
@@ -196,68 +196,61 @@ declare const CHANNEL_EVENT_PREFIX = "channel:";
|
|
|
196
196
|
type AdminListPayload = {
|
|
197
197
|
page?: number;
|
|
198
198
|
pageSize?: number;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
direction?: "asc" | "desc";
|
|
202
|
-
};
|
|
203
|
-
filter?: {
|
|
204
|
-
id?: string;
|
|
205
|
-
ids?: string[];
|
|
206
|
-
createdAfter?: string;
|
|
207
|
-
createdBefore?: string;
|
|
208
|
-
updatedAfter?: string;
|
|
209
|
-
updatedBefore?: string;
|
|
210
|
-
} & Record<string, unknown>;
|
|
199
|
+
where?: Record<string, unknown>;
|
|
200
|
+
orderBy?: Record<string, "asc" | "desc">;
|
|
211
201
|
};
|
|
212
202
|
type AdminListResponse<T> = {
|
|
213
|
-
|
|
203
|
+
items: T[];
|
|
204
|
+
total: number;
|
|
214
205
|
page: number;
|
|
215
206
|
pageSize: number;
|
|
216
|
-
|
|
207
|
+
totalPages: number;
|
|
217
208
|
};
|
|
218
209
|
type AdminGetPayload = {
|
|
219
210
|
id: string;
|
|
220
211
|
};
|
|
221
|
-
type AdminCreatePayload<
|
|
222
|
-
data:
|
|
212
|
+
type AdminCreatePayload<TCreate> = {
|
|
213
|
+
data: TCreate;
|
|
223
214
|
};
|
|
224
|
-
type AdminUpdatePayload<
|
|
215
|
+
type AdminUpdatePayload<TUpdate> = {
|
|
225
216
|
id: string;
|
|
226
|
-
data:
|
|
217
|
+
data: TUpdate;
|
|
227
218
|
};
|
|
228
219
|
type AdminDeletePayload = {
|
|
229
220
|
id: string;
|
|
230
221
|
};
|
|
231
222
|
type AdminDeleteResponse = {
|
|
223
|
+
success: boolean;
|
|
232
224
|
id: string;
|
|
233
|
-
deleted: true;
|
|
234
225
|
};
|
|
235
|
-
type
|
|
236
|
-
|
|
226
|
+
type AdminSetACLPayload = {
|
|
227
|
+
entryId: string;
|
|
237
228
|
acl: ACL;
|
|
238
229
|
};
|
|
239
230
|
type AdminGetSubscribersPayload = {
|
|
240
|
-
|
|
231
|
+
entryId: string;
|
|
241
232
|
};
|
|
242
|
-
type
|
|
243
|
-
|
|
233
|
+
type AdminSubscribersResponse = {
|
|
234
|
+
entryId: string;
|
|
244
235
|
subscribers: Array<{
|
|
245
236
|
socketId: string;
|
|
246
|
-
userId
|
|
237
|
+
userId: string | null;
|
|
247
238
|
}>;
|
|
239
|
+
count: number;
|
|
248
240
|
};
|
|
249
241
|
type AdminReemitPayload = {
|
|
250
|
-
|
|
242
|
+
entryId: string;
|
|
251
243
|
};
|
|
252
244
|
type AdminReemitResponse = {
|
|
253
|
-
|
|
245
|
+
success: boolean;
|
|
246
|
+
subscriberCount: number;
|
|
254
247
|
};
|
|
255
248
|
type AdminUnsubscribeAllPayload = {
|
|
256
|
-
|
|
249
|
+
entryId: string;
|
|
257
250
|
};
|
|
258
251
|
type AdminUnsubscribeAllResponse = {
|
|
259
|
-
|
|
260
|
-
|
|
252
|
+
success: boolean;
|
|
253
|
+
unsubscribedCount: number;
|
|
261
254
|
};
|
|
262
255
|
/**
|
|
263
256
|
* Supported field types for admin UI generation.
|
|
@@ -314,6 +307,136 @@ type SubscribePayload = {
|
|
|
314
307
|
type UnsubscribePayload = {
|
|
315
308
|
entryId: string;
|
|
316
309
|
};
|
|
310
|
+
/**
|
|
311
|
+
* The Socket.io room for an entity subscription: `{serviceName}:{entryId}`.
|
|
312
|
+
*
|
|
313
|
+
* Static counterpart to `BaseService.getRoomName` — usable from shared
|
|
314
|
+
* modules and when emitting into *another* service's room without holding
|
|
315
|
+
* its instance.
|
|
316
|
+
*/
|
|
317
|
+
declare function serviceRoom(serviceName: string, entryId: string): string;
|
|
318
|
+
/**
|
|
319
|
+
* The room subscribers of an elevated (unfiltered) entity tier join in
|
|
320
|
+
* addition to {@link serviceRoom}. Emission targets it via
|
|
321
|
+
* `io.to(fullRoom)` + `io.to(room).except(fullRoom)`.
|
|
322
|
+
*/
|
|
323
|
+
declare function serviceFullRoom(serviceName: string, entryId: string): string;
|
|
324
|
+
/**
|
|
325
|
+
* The room for a collection scope:
|
|
326
|
+
* `{serviceName}:collection:{collection}:{scopeId}`.
|
|
327
|
+
* Entity ids are cuids, so this never collides with {@link serviceRoom}.
|
|
328
|
+
*/
|
|
329
|
+
declare function collectionRoom(serviceName: string, collection: string, scopeId: string): string;
|
|
330
|
+
/**
|
|
331
|
+
* The event name collection deltas are emitted as. Identical to the room
|
|
332
|
+
* name by design — one event per scope room, no id parsing client-side.
|
|
333
|
+
*/
|
|
334
|
+
declare function collectionEventName(serviceName: string, collection: string, scopeId: string): string;
|
|
335
|
+
/**
|
|
336
|
+
* The per-user room every authenticated socket joins on connection:
|
|
337
|
+
* `user:{userId}`. Used for targeted notifications and adapter-safe
|
|
338
|
+
* revocation (`kickFromCollection`).
|
|
339
|
+
*/
|
|
340
|
+
declare function userRoom(userId: string): string;
|
|
341
|
+
/**
|
|
342
|
+
* Augmentable map of custom room event names to their payload types.
|
|
343
|
+
* Empty by default — `emitToRoom` and `useRoomEvents` degrade gracefully to
|
|
344
|
+
* `string` names and `unknown` payloads until an app augments it:
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* ```typescript
|
|
348
|
+
* // In your app's shared types
|
|
349
|
+
* declare module "@fitzzero/quickdraw-core" {
|
|
350
|
+
* interface QuickdrawEventMap {
|
|
351
|
+
* "chat:memberUpdate": { chatId: string; userId: string };
|
|
352
|
+
* "presence:changed": { userId: string; online: boolean };
|
|
353
|
+
* }
|
|
354
|
+
* }
|
|
355
|
+
* ```
|
|
356
|
+
*
|
|
357
|
+
* Collection deltas and `{service}:update:{id}` events need no entries here —
|
|
358
|
+
* they are framework-generated and typed end-to-end by the service generics.
|
|
359
|
+
*/
|
|
360
|
+
interface QuickdrawEventMap {
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Event names accepted by `emitToRoom`/`useRoomEvents`: keys of the
|
|
364
|
+
* augmented {@link QuickdrawEventMap} (with autocomplete) plus any string.
|
|
365
|
+
*/
|
|
366
|
+
type QuickdrawEventName = (keyof QuickdrawEventMap & string) | (string & Record<never, never>);
|
|
367
|
+
/**
|
|
368
|
+
* Payload type for a room event: the mapped type when the name is in
|
|
369
|
+
* {@link QuickdrawEventMap}, `unknown` otherwise.
|
|
370
|
+
*/
|
|
371
|
+
type QuickdrawEventData<E extends string> = E extends keyof QuickdrawEventMap ? QuickdrawEventMap[E] : unknown;
|
|
372
|
+
/**
|
|
373
|
+
* One page of a collection snapshot, returned by a collection's `snapshot`
|
|
374
|
+
* query and by the `{service}:collection:subscribe` ack.
|
|
375
|
+
*/
|
|
376
|
+
interface CollectionSnapshotPage<TItem> {
|
|
377
|
+
items: TItem[];
|
|
378
|
+
nextCursor: string | null;
|
|
379
|
+
totalCount: number;
|
|
380
|
+
/**
|
|
381
|
+
* Full membership id list for the scope (ids only, cheap select).
|
|
382
|
+
* Returned on cursor-less (first-page / re-snapshot) requests so clients
|
|
383
|
+
* can prune rows deleted while disconnected. Omit for unbounded scopes
|
|
384
|
+
* (e.g. long chat histories) — the client then merges without pruning.
|
|
385
|
+
*/
|
|
386
|
+
ids?: string[];
|
|
387
|
+
/**
|
|
388
|
+
* Set by the server when the scope's id list exceeded the cap (5,000) and
|
|
389
|
+
* `ids` was dropped — the client degrades to no-prune merges.
|
|
390
|
+
*/
|
|
391
|
+
idsTruncated?: boolean;
|
|
392
|
+
}
|
|
393
|
+
/** Snapshot page plus the revision captured before the query ran. */
|
|
394
|
+
type CollectionSnapshotResponse<TItem> = CollectionSnapshotPage<TItem> & {
|
|
395
|
+
rev: number;
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* Payload for `{service}:collection:subscribe`. Cursor-less calls join the
|
|
399
|
+
* scope room and may include `ids`; cursor-bearing calls are pure paging —
|
|
400
|
+
* no room join, no `ids`.
|
|
401
|
+
*/
|
|
402
|
+
type CollectionSubscribePayload = {
|
|
403
|
+
collection: string;
|
|
404
|
+
scopeId: string;
|
|
405
|
+
cursor?: string | null;
|
|
406
|
+
limit?: number;
|
|
407
|
+
};
|
|
408
|
+
/** Payload for `{service}:collection:unsubscribe`. */
|
|
409
|
+
type CollectionUnsubscribePayload = {
|
|
410
|
+
collection: string;
|
|
411
|
+
scopeId: string;
|
|
412
|
+
};
|
|
413
|
+
/**
|
|
414
|
+
* A collection change, emitted to the scope room under the event name
|
|
415
|
+
* {@link collectionEventName}.
|
|
416
|
+
*
|
|
417
|
+
* `rev` is per-item last-writer-wins: clients ignore an `added`/`updated`
|
|
418
|
+
* older than what they hold for that id, and snapshot items only overwrite
|
|
419
|
+
* cached items whose rev predates the snapshot rev. Revisions are epoch
|
|
420
|
+
* milliseconds (`Date.now()` at emit, or the collection's `revOf`).
|
|
421
|
+
*/
|
|
422
|
+
type CollectionDelta<TItem extends {
|
|
423
|
+
id: string;
|
|
424
|
+
}> = {
|
|
425
|
+
type: "added";
|
|
426
|
+
item: TItem;
|
|
427
|
+
rev: number;
|
|
428
|
+
} | {
|
|
429
|
+
type: "updated";
|
|
430
|
+
item: TItem;
|
|
431
|
+
rev: number;
|
|
432
|
+
} | {
|
|
433
|
+
type: "removed";
|
|
434
|
+
id: string;
|
|
435
|
+
rev: number;
|
|
436
|
+
} | {
|
|
437
|
+
type: "reset";
|
|
438
|
+
rev: number;
|
|
439
|
+
};
|
|
317
440
|
/**
|
|
318
441
|
* Extracts payload type from a service method definition
|
|
319
442
|
*/
|
|
@@ -341,4 +464,4 @@ interface Logger {
|
|
|
341
464
|
*/
|
|
342
465
|
declare const consoleLogger: Logger;
|
|
343
466
|
|
|
344
|
-
export { type ACE, type ACL, type ACLEntity, type AccessLevel, type AdminCreatePayload, type AdminDeletePayload, type AdminDeleteResponse, type AdminFieldConfig, type AdminFieldType, type AdminGetPayload, type AdminGetSubscribersPayload, type
|
|
467
|
+
export { type ACE, type ACL, type ACLEntity, type AccessLevel, type AdminCreatePayload, type AdminDeletePayload, type AdminDeleteResponse, type AdminFieldConfig, type AdminFieldType, type AdminGetPayload, type AdminGetSubscribersPayload, type AdminListPayload, type AdminListResponse, type AdminMetaPayload, type AdminMetaResponse, type AdminReemitPayload, type AdminReemitResponse, type AdminServiceMeta, type AdminSetACLPayload, type AdminSubscribersResponse, type AdminUnsubscribeAllPayload, type AdminUnsubscribeAllResponse, type AdminUpdatePayload, CHANNEL_EVENT_PREFIX, type CollectionDelta, type CollectionSnapshotPage, type CollectionSnapshotResponse, type CollectionSubscribePayload, type CollectionUnsubscribePayload, type ExtractPayload, type ExtractResponse, type Logger, type QuickdrawEventData, type QuickdrawEventMap, type QuickdrawEventName, type QuickdrawUser, type ServiceChannelContext, type ServiceChannelDefinition, type ServiceChannelMap, type ServiceMethodContext, type ServiceMethodDefinition, type ServiceMethodMap, type ServiceResponse, type SubscribePayload, type UnsubscribePayload, channelEventName, collectionEventName, collectionRoom, consoleLogger, serviceFullRoom, serviceRoom, userRoom };
|
package/dist/shared/index.js
CHANGED
|
@@ -3,6 +3,21 @@ function channelEventName(serviceName, channelName) {
|
|
|
3
3
|
return `channel:${serviceName}:${channelName}`;
|
|
4
4
|
}
|
|
5
5
|
var CHANNEL_EVENT_PREFIX = "channel:";
|
|
6
|
+
function serviceRoom(serviceName, entryId) {
|
|
7
|
+
return `${serviceName}:${entryId}`;
|
|
8
|
+
}
|
|
9
|
+
function serviceFullRoom(serviceName, entryId) {
|
|
10
|
+
return `${serviceRoom(serviceName, entryId)}:full`;
|
|
11
|
+
}
|
|
12
|
+
function collectionRoom(serviceName, collection, scopeId) {
|
|
13
|
+
return `${serviceName}:collection:${collection}:${scopeId}`;
|
|
14
|
+
}
|
|
15
|
+
function collectionEventName(serviceName, collection, scopeId) {
|
|
16
|
+
return collectionRoom(serviceName, collection, scopeId);
|
|
17
|
+
}
|
|
18
|
+
function userRoom(userId) {
|
|
19
|
+
return `user:${userId}`;
|
|
20
|
+
}
|
|
6
21
|
var consoleLogger = {
|
|
7
22
|
info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? ""),
|
|
8
23
|
warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? ""),
|
|
@@ -11,6 +26,6 @@ var consoleLogger = {
|
|
|
11
26
|
child: () => consoleLogger
|
|
12
27
|
};
|
|
13
28
|
|
|
14
|
-
export { CHANNEL_EVENT_PREFIX, channelEventName, consoleLogger };
|
|
29
|
+
export { CHANNEL_EVENT_PREFIX, channelEventName, collectionEventName, collectionRoom, consoleLogger, serviceFullRoom, serviceRoom, userRoom };
|
|
15
30
|
//# sourceMappingURL=index.js.map
|
|
16
31
|
//# sourceMappingURL=index.js.map
|
package/dist/shared/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shared/types.ts"],"names":[],"mappings":";AAoNO,SAAS,gBAAA,CAAiB,aAAqB,WAAA,EAA6B;AACjF,EAAA,OAAO,CAAA,QAAA,EAAW,WAAW,CAAA,CAAA,EAAI,WAAW,CAAA,CAAA;AAC9C;AAOO,IAAM,oBAAA,GAAuB;AAoK7B,IAAM,aAAA,GAAwB;AAAA,EACnC,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,IAAI,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACpE,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,KAAK,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACrE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,OAAO,MAAM;AACf","file":"index.js","sourcesContent":["import type { z } from \"zod\";\n\n// ============================================================================\n// Access Control Types\n// ============================================================================\n\nexport type AccessLevel = \"Public\" | \"Read\" | \"Moderate\" | \"Admin\";\n\nexport type ACE = {\n userId: string;\n level: AccessLevel;\n};\n\nexport type ACL = ACE[];\n\n// ============================================================================\n// Entity and User Interfaces for ACL\n// ============================================================================\n\n/**\n * Interface for entities that support entry-level ACL stored as JSON.\n *\n * Use this pattern when:\n * - You want simple ACL without a separate membership table\n * - Access patterns are straightforward (read/write permissions)\n * - You don't need to query \"all entities user X can access\" efficiently\n *\n * The default `checkEntryACL` in BaseService expects entities to have this shape.\n *\n * @example\n * ```prisma\n * model Document {\n * id String @id @default(cuid())\n * title String\n * acl Json? // Stores ACL array\n * }\n * ```\n *\n * @example\n * ```typescript\n * class DocumentService extends BaseService<Document & ACLEntity, ...> {\n * constructor(prisma: PrismaClient) {\n * super({ serviceName: 'documentService', hasEntryACL: true });\n * this.setDelegate(prisma.document);\n * // Default checkEntryACL will work automatically\n * }\n * }\n * ```\n */\nexport interface ACLEntity {\n id: string;\n acl?: ACL | null;\n}\n\n/**\n * Interface for users with service-level access control.\n *\n * The `serviceAccess` field maps service names to access levels, granting\n * blanket permissions across all entries in that service.\n *\n * @example\n * ```prisma\n * model User {\n * id String @id @default(cuid())\n * serviceAccess Json? // { \"chatService\": \"Admin\", \"documentService\": \"Read\" }\n * }\n * ```\n *\n * @example\n * ```typescript\n * // User with Admin access to chatService can perform any operation\n * // on any chat, regardless of entry-level ACL\n * const user: QuickdrawUser = {\n * id: \"user-123\",\n * serviceAccess: { chatService: \"Admin\" }\n * };\n * ```\n */\nexport interface QuickdrawUser {\n id: string;\n serviceAccess?: Record<string, AccessLevel> | null;\n}\n\n// ============================================================================\n// Service Response Types\n// ============================================================================\n\nexport type ServiceResponse<T = unknown> =\n | { success: true; data: T }\n | { success: false; error: string; code?: number };\n\n// ============================================================================\n// Service Method Definition Types\n// ============================================================================\n\n/**\n * Defines the shape of a service method including its payload and response types.\n * Used to create a type-safe contract between server and client.\n */\nexport interface ServiceMethodDefinition<TPayload = unknown, TResponse = unknown> {\n name: string;\n access: AccessLevel;\n handler: (payload: TPayload, context: ServiceMethodContext) => Promise<TResponse>;\n schema?: z.ZodType<TPayload>;\n resolveEntryId?: (payload: TPayload) => string | null;\n}\n\n/**\n * Context provided to service method handlers\n */\nexport interface ServiceMethodContext {\n userId: string | undefined;\n socketId: string;\n serviceAccess: Record<string, AccessLevel>;\n}\n\n/**\n * Type helper for defining service method maps.\n * Each service should define its methods using this structure.\n *\n * @example\n * ```typescript\n * type ChatServiceMethods = ServiceMethodMap<{\n * createChat: {\n * payload: { title: string };\n * response: { id: string };\n * };\n * updateTitle: {\n * payload: { id: string; title: string };\n * response: { id: string; title: string };\n * };\n * }>;\n * ```\n */\nexport type ServiceMethodMap<T extends Record<string, { payload: unknown; response: unknown }>> = T;\n\n// ============================================================================\n// Service Channel Types (high-frequency, fire-and-forget events)\n// ============================================================================\n\n/**\n * Channels are the high-frequency counterpart to service methods.\n *\n * Where methods are request/response (ack'd, ACL-checked against the database,\n * counted by the global rate limiter), channels are fire-and-forget: no ack,\n * no response, validated and access-checked entirely in memory, and governed\n * by a per-channel token bucket instead of the global limiter. Use them for\n * traffic where per-message overhead matters and losing a message is fine —\n * game input, cursor positions, typing indicators, telemetry.\n *\n * Wire format: channels route as the Socket.io event\n * `channel:<serviceName>:<channelName>` (see {@link channelEventName}).\n * Clients should emit them volatile; servers broadcast tick data back with\n * `emitToRoomVolatile` so backpressured clients drop frames instead of\n * queueing them.\n *\n * Access model (all checks synchronous, zero DB reads):\n * - Authentication is always required — anonymous sockets are dropped silently.\n * - `\"Public\"` / `\"Read\"` access: any authenticated user passes the service gate.\n * - `\"Moderate\"` / `\"Admin\"`: requires that level in the socket's in-memory\n * `serviceAccess`.\n * - Entry-level access is expressed via `requireRoom`: the payload resolves to\n * a room name and the socket must already be in that room. Room membership\n * was ACL-checked at subscribe time, so this inherits full entry ACL\n * semantics without touching the database on the hot path.\n */\nexport interface ServiceChannelDefinition<TPayload = unknown> {\n name: string;\n access: AccessLevel;\n handler: (payload: TPayload, context: ServiceChannelContext) => void;\n /** Required — channel payloads are untrusted high-frequency input. */\n schema: z.ZodType<TPayload>;\n /** Sustained messages per second allowed per socket. */\n ratePerSecond: number;\n /** Bucket capacity — short bursts above ratePerSecond up to this size. */\n burst: number;\n /**\n * Resolve a room the socket must already be a member of (typically the\n * service room joined via subscribe). Return null to skip the check.\n */\n requireRoom?: (payload: TPayload) => string | null;\n}\n\n/**\n * Context provided to channel handlers. Unlike ServiceMethodContext,\n * userId is always present — unauthenticated channel messages are dropped\n * before the handler runs.\n */\nexport interface ServiceChannelContext {\n userId: string;\n socketId: string;\n serviceAccess: Record<string, AccessLevel>;\n}\n\n/**\n * Type helper for defining service channel maps (payload-only — channels\n * have no response).\n *\n * @example\n * ```typescript\n * type GameServiceChannels = ServiceChannelMap<{\n * input: { seq: number; dx: number; dy: number; boost: boolean };\n * }>;\n * ```\n */\nexport type ServiceChannelMap<T extends Record<string, unknown>> = T;\n\n/**\n * The Socket.io event name a channel routes as.\n * Shared by the server registry, the React client, and non-JS clients\n * (e.g. the Godot addon) so the wire contract lives in one place.\n */\nexport function channelEventName(serviceName: string, channelName: string): string {\n return `channel:${serviceName}:${channelName}`;\n}\n\n/**\n * Prefix for all channel events — pass to the rate limiter's\n * `excludePrefixes` so channel traffic bypasses the global request limiter\n * (channels enforce their own per-socket token buckets).\n */\nexport const CHANNEL_EVENT_PREFIX = \"channel:\";\n\n// ============================================================================\n// Admin Method Types (Generic shapes for all services)\n// ============================================================================\n\nexport type AdminListPayload = {\n page?: number;\n pageSize?: number;\n sort?: {\n field?: string;\n direction?: \"asc\" | \"desc\";\n };\n filter?: {\n id?: string;\n ids?: string[];\n createdAfter?: string;\n createdBefore?: string;\n updatedAfter?: string;\n updatedBefore?: string;\n } & Record<string, unknown>;\n};\n\nexport type AdminListResponse<T> = {\n rows: T[];\n page: number;\n pageSize: number;\n total: number;\n};\n\nexport type AdminGetPayload = { id: string };\n\nexport type AdminCreatePayload<TInsert> = { data: Partial<TInsert> };\n\nexport type AdminUpdatePayload<TInsert> = {\n id: string;\n data: Partial<TInsert>;\n};\n\nexport type AdminDeletePayload = { id: string };\nexport type AdminDeleteResponse = { id: string; deleted: true };\n\nexport type AdminSetEntryACLPayload = {\n id: string;\n acl: ACL;\n};\n\nexport type AdminGetSubscribersPayload = { id: string };\nexport type AdminGetSubscribersResponse = {\n id: string;\n subscribers: Array<{ socketId: string; userId?: string }>;\n};\n\nexport type AdminReemitPayload = { id: string };\nexport type AdminReemitResponse = { emitted: boolean };\n\nexport type AdminUnsubscribeAllPayload = { id: string };\nexport type AdminUnsubscribeAllResponse = { id: string; unsubscribed: number };\n\n// ============================================================================\n// Admin Field Configuration Types\n// ============================================================================\n\n/**\n * Supported field types for admin UI generation.\n */\nexport type AdminFieldType =\n | \"string\"\n | \"number\"\n | \"boolean\"\n | \"date\"\n | \"enum\"\n | \"json\"\n | \"relation\";\n\n/**\n * Configuration for a single field in the admin UI.\n * Derived from Zod schemas with optional overrides.\n */\nexport interface AdminFieldConfig {\n /** Field name (property key on the entity) */\n name: string;\n /** Field type for rendering appropriate input */\n type: AdminFieldType;\n /** Human-readable label */\n label: string;\n /** Whether the field is required for creation */\n required: boolean;\n /** Whether the field can be edited (false for id, createdAt, etc.) */\n editable: boolean;\n /** Whether to show this field as a table column */\n showInTable: boolean;\n /** Whether the table can be sorted by this field */\n sortable: boolean;\n /** For enum fields, the allowed values */\n enumValues?: string[];\n /** For relation fields, the related service name */\n relationService?: string;\n}\n\n/**\n * Service metadata for admin UI generation.\n * Returned by the adminMeta method.\n */\nexport interface AdminServiceMeta {\n /** Internal service name (e.g., \"chatService\") */\n serviceName: string;\n /** Human-readable display name (e.g., \"Chats\") */\n displayName: string;\n /** Field configurations derived from schema */\n fields: AdminFieldConfig[];\n}\n\n/**\n * Payload for adminMeta method (no parameters needed)\n */\nexport type AdminMetaPayload = Record<string, never>;\n\n/**\n * Response type for adminMeta method\n */\nexport type AdminMetaResponse = AdminServiceMeta;\n\n// ============================================================================\n// Subscription Types\n// ============================================================================\n\nexport type SubscribePayload = {\n entryId: string;\n requiredLevel?: AccessLevel;\n};\n\nexport type UnsubscribePayload = {\n entryId: string;\n};\n\n// ============================================================================\n// Utility Types\n// ============================================================================\n\n/**\n * Extracts payload type from a service method definition\n */\nexport type ExtractPayload<T> = T extends { payload: infer P } ? P : never;\n\n/**\n * Extracts response type from a service method definition\n */\nexport type ExtractResponse<T> = T extends { response: infer R } ? R : never;\n\n/**\n * Logger interface compatible with Winston and other loggers\n */\nexport interface Logger {\n info(message: string, meta?: Record<string, unknown>): void;\n warn(message: string, meta?: Record<string, unknown>): void;\n error(message: string, meta?: Record<string, unknown>): void;\n debug(message: string, meta?: Record<string, unknown>): void;\n child(options: Record<string, unknown>): Logger;\n}\n\n/**\n * Default console logger implementation\n */\nexport const consoleLogger: Logger = {\n info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? \"\"),\n warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? \"\"),\n error: (message, meta) => console.error(`[ERROR] ${message}`, meta ?? \"\"),\n debug: (message, meta) => console.debug(`[DEBUG] ${message}`, meta ?? \"\"),\n child: () => consoleLogger,\n};\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/shared/types.ts"],"names":[],"mappings":";AAoNO,SAAS,gBAAA,CAAiB,aAAqB,WAAA,EAA6B;AACjF,EAAA,OAAO,CAAA,QAAA,EAAW,WAAW,CAAA,CAAA,EAAI,WAAW,CAAA,CAAA;AAC9C;AAOO,IAAM,oBAAA,GAAuB;AA8I7B,SAAS,WAAA,CAAY,aAAqB,OAAA,EAAyB;AACxE,EAAA,OAAO,CAAA,EAAG,WAAW,CAAA,CAAA,EAAI,OAAO,CAAA,CAAA;AAClC;AAOO,SAAS,eAAA,CAAgB,aAAqB,OAAA,EAAyB;AAC5E,EAAA,OAAO,CAAA,EAAG,WAAA,CAAY,WAAA,EAAa,OAAO,CAAC,CAAA,KAAA,CAAA;AAC7C;AAOO,SAAS,cAAA,CAAe,WAAA,EAAqB,UAAA,EAAoB,OAAA,EAAyB;AAC/F,EAAA,OAAO,CAAA,EAAG,WAAW,CAAA,YAAA,EAAe,UAAU,IAAI,OAAO,CAAA,CAAA;AAC3D;AAMO,SAAS,mBAAA,CACd,WAAA,EACA,UAAA,EACA,OAAA,EACQ;AACR,EAAA,OAAO,cAAA,CAAe,WAAA,EAAa,UAAA,EAAY,OAAO,CAAA;AACxD;AAOO,SAAS,SAAS,MAAA,EAAwB;AAC/C,EAAA,OAAO,QAAQ,MAAM,CAAA,CAAA;AACvB;AAsIO,IAAM,aAAA,GAAwB;AAAA,EACnC,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,IAAI,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACpE,IAAA,EAAM,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,KAAK,CAAA,OAAA,EAAU,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACrE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,KAAA,EAAO,CAAC,OAAA,EAAS,IAAA,KAAS,OAAA,CAAQ,MAAM,CAAA,QAAA,EAAW,OAAO,CAAA,CAAA,EAAI,IAAA,IAAQ,EAAE,CAAA;AAAA,EACxE,OAAO,MAAM;AACf","file":"index.js","sourcesContent":["import type { z } from \"zod\";\n\n// ============================================================================\n// Access Control Types\n// ============================================================================\n\nexport type AccessLevel = \"Public\" | \"Read\" | \"Moderate\" | \"Admin\";\n\nexport type ACE = {\n userId: string;\n level: AccessLevel;\n};\n\nexport type ACL = ACE[];\n\n// ============================================================================\n// Entity and User Interfaces for ACL\n// ============================================================================\n\n/**\n * Interface for entities that support entry-level ACL stored as JSON.\n *\n * Use this pattern when:\n * - You want simple ACL without a separate membership table\n * - Access patterns are straightforward (read/write permissions)\n * - You don't need to query \"all entities user X can access\" efficiently\n *\n * The default `checkEntryACL` in BaseService expects entities to have this shape.\n *\n * @example\n * ```prisma\n * model Document {\n * id String @id @default(cuid())\n * title String\n * acl Json? // Stores ACL array\n * }\n * ```\n *\n * @example\n * ```typescript\n * class DocumentService extends BaseService<Document & ACLEntity, ...> {\n * constructor(prisma: PrismaClient) {\n * super({ serviceName: 'documentService', hasEntryACL: true });\n * this.setDelegate(prisma.document);\n * // Default checkEntryACL will work automatically\n * }\n * }\n * ```\n */\nexport interface ACLEntity {\n id: string;\n acl?: ACL | null;\n}\n\n/**\n * Interface for users with service-level access control.\n *\n * The `serviceAccess` field maps service names to access levels, granting\n * blanket permissions across all entries in that service.\n *\n * @example\n * ```prisma\n * model User {\n * id String @id @default(cuid())\n * serviceAccess Json? // { \"chatService\": \"Admin\", \"documentService\": \"Read\" }\n * }\n * ```\n *\n * @example\n * ```typescript\n * // User with Admin access to chatService can perform any operation\n * // on any chat, regardless of entry-level ACL\n * const user: QuickdrawUser = {\n * id: \"user-123\",\n * serviceAccess: { chatService: \"Admin\" }\n * };\n * ```\n */\nexport interface QuickdrawUser {\n id: string;\n serviceAccess?: Record<string, AccessLevel> | null;\n}\n\n// ============================================================================\n// Service Response Types\n// ============================================================================\n\nexport type ServiceResponse<T = unknown> =\n | { success: true; data: T }\n | { success: false; error: string; code?: number };\n\n// ============================================================================\n// Service Method Definition Types\n// ============================================================================\n\n/**\n * Defines the shape of a service method including its payload and response types.\n * Used to create a type-safe contract between server and client.\n */\nexport interface ServiceMethodDefinition<TPayload = unknown, TResponse = unknown> {\n name: string;\n access: AccessLevel;\n handler: (payload: TPayload, context: ServiceMethodContext) => Promise<TResponse>;\n schema?: z.ZodType<TPayload>;\n resolveEntryId?: (payload: TPayload) => string | null;\n}\n\n/**\n * Context provided to service method handlers\n */\nexport interface ServiceMethodContext {\n userId: string | undefined;\n socketId: string;\n serviceAccess: Record<string, AccessLevel>;\n}\n\n/**\n * Type helper for defining service method maps.\n * Each service should define its methods using this structure.\n *\n * @example\n * ```typescript\n * type ChatServiceMethods = ServiceMethodMap<{\n * createChat: {\n * payload: { title: string };\n * response: { id: string };\n * };\n * updateTitle: {\n * payload: { id: string; title: string };\n * response: { id: string; title: string };\n * };\n * }>;\n * ```\n */\nexport type ServiceMethodMap<T extends Record<string, { payload: unknown; response: unknown }>> = T;\n\n// ============================================================================\n// Service Channel Types (high-frequency, fire-and-forget events)\n// ============================================================================\n\n/**\n * Channels are the high-frequency counterpart to service methods.\n *\n * Where methods are request/response (ack'd, ACL-checked against the database,\n * counted by the global rate limiter), channels are fire-and-forget: no ack,\n * no response, validated and access-checked entirely in memory, and governed\n * by a per-channel token bucket instead of the global limiter. Use them for\n * traffic where per-message overhead matters and losing a message is fine —\n * game input, cursor positions, typing indicators, telemetry.\n *\n * Wire format: channels route as the Socket.io event\n * `channel:<serviceName>:<channelName>` (see {@link channelEventName}).\n * Clients should emit them volatile; servers broadcast tick data back with\n * `emitToRoomVolatile` so backpressured clients drop frames instead of\n * queueing them.\n *\n * Access model (all checks synchronous, zero DB reads):\n * - Authentication is always required — anonymous sockets are dropped silently.\n * - `\"Public\"` / `\"Read\"` access: any authenticated user passes the service gate.\n * - `\"Moderate\"` / `\"Admin\"`: requires that level in the socket's in-memory\n * `serviceAccess`.\n * - Entry-level access is expressed via `requireRoom`: the payload resolves to\n * a room name and the socket must already be in that room. Room membership\n * was ACL-checked at subscribe time, so this inherits full entry ACL\n * semantics without touching the database on the hot path.\n */\nexport interface ServiceChannelDefinition<TPayload = unknown> {\n name: string;\n access: AccessLevel;\n handler: (payload: TPayload, context: ServiceChannelContext) => void;\n /** Required — channel payloads are untrusted high-frequency input. */\n schema: z.ZodType<TPayload>;\n /** Sustained messages per second allowed per socket. */\n ratePerSecond: number;\n /** Bucket capacity — short bursts above ratePerSecond up to this size. */\n burst: number;\n /**\n * Resolve a room the socket must already be a member of (typically the\n * service room joined via subscribe). Return null to skip the check.\n */\n requireRoom?: (payload: TPayload) => string | null;\n}\n\n/**\n * Context provided to channel handlers. Unlike ServiceMethodContext,\n * userId is always present — unauthenticated channel messages are dropped\n * before the handler runs.\n */\nexport interface ServiceChannelContext {\n userId: string;\n socketId: string;\n serviceAccess: Record<string, AccessLevel>;\n}\n\n/**\n * Type helper for defining service channel maps (payload-only — channels\n * have no response).\n *\n * @example\n * ```typescript\n * type GameServiceChannels = ServiceChannelMap<{\n * input: { seq: number; dx: number; dy: number; boost: boolean };\n * }>;\n * ```\n */\nexport type ServiceChannelMap<T extends Record<string, unknown>> = T;\n\n/**\n * The Socket.io event name a channel routes as.\n * Shared by the server registry, the React client, and non-JS clients\n * (e.g. the Godot addon) so the wire contract lives in one place.\n */\nexport function channelEventName(serviceName: string, channelName: string): string {\n return `channel:${serviceName}:${channelName}`;\n}\n\n/**\n * Prefix for all channel events — pass to the rate limiter's\n * `excludePrefixes` so channel traffic bypasses the global request limiter\n * (channels enforce their own per-socket token buckets).\n */\nexport const CHANNEL_EVENT_PREFIX = \"channel:\";\n\n// ============================================================================\n// Admin Method Types (Generic shapes for all services)\n// ============================================================================\n// One canonical shape per admin method, matching what BaseService actually\n// sends and receives on the wire. (Before 4.0 these were declared twice —\n// here and in server/types.ts — with different shapes; the server shapes won.)\n\nexport type AdminListPayload = {\n page?: number;\n pageSize?: number;\n where?: Record<string, unknown>;\n orderBy?: Record<string, \"asc\" | \"desc\">;\n};\n\nexport type AdminListResponse<T> = {\n items: T[];\n total: number;\n page: number;\n pageSize: number;\n totalPages: number;\n};\n\nexport type AdminGetPayload = { id: string };\n\nexport type AdminCreatePayload<TCreate> = { data: TCreate };\n\nexport type AdminUpdatePayload<TUpdate> = {\n id: string;\n data: TUpdate;\n};\n\nexport type AdminDeletePayload = { id: string };\nexport type AdminDeleteResponse = { success: boolean; id: string };\n\nexport type AdminSetACLPayload = {\n entryId: string;\n acl: ACL;\n};\n\nexport type AdminGetSubscribersPayload = { entryId: string };\nexport type AdminSubscribersResponse = {\n entryId: string;\n subscribers: Array<{ socketId: string; userId: string | null }>;\n count: number;\n};\n\nexport type AdminReemitPayload = { entryId: string };\nexport type AdminReemitResponse = { success: boolean; subscriberCount: number };\n\nexport type AdminUnsubscribeAllPayload = { entryId: string };\nexport type AdminUnsubscribeAllResponse = { success: boolean; unsubscribedCount: number };\n\n// ============================================================================\n// Admin Field Configuration Types\n// ============================================================================\n\n/**\n * Supported field types for admin UI generation.\n */\nexport type AdminFieldType =\n | \"string\"\n | \"number\"\n | \"boolean\"\n | \"date\"\n | \"enum\"\n | \"json\"\n | \"relation\";\n\n/**\n * Configuration for a single field in the admin UI.\n * Derived from Zod schemas with optional overrides.\n */\nexport interface AdminFieldConfig {\n /** Field name (property key on the entity) */\n name: string;\n /** Field type for rendering appropriate input */\n type: AdminFieldType;\n /** Human-readable label */\n label: string;\n /** Whether the field is required for creation */\n required: boolean;\n /** Whether the field can be edited (false for id, createdAt, etc.) */\n editable: boolean;\n /** Whether to show this field as a table column */\n showInTable: boolean;\n /** Whether the table can be sorted by this field */\n sortable: boolean;\n /** For enum fields, the allowed values */\n enumValues?: string[];\n /** For relation fields, the related service name */\n relationService?: string;\n}\n\n/**\n * Service metadata for admin UI generation.\n * Returned by the adminMeta method.\n */\nexport interface AdminServiceMeta {\n /** Internal service name (e.g., \"chatService\") */\n serviceName: string;\n /** Human-readable display name (e.g., \"Chats\") */\n displayName: string;\n /** Field configurations derived from schema */\n fields: AdminFieldConfig[];\n}\n\n/**\n * Payload for adminMeta method (no parameters needed)\n */\nexport type AdminMetaPayload = Record<string, never>;\n\n/**\n * Response type for adminMeta method\n */\nexport type AdminMetaResponse = AdminServiceMeta;\n\n// ============================================================================\n// Subscription Types\n// ============================================================================\n\nexport type SubscribePayload = {\n entryId: string;\n requiredLevel?: AccessLevel;\n};\n\nexport type UnsubscribePayload = {\n entryId: string;\n};\n\n// ============================================================================\n// Room Naming (shared between server emitters and client listeners)\n// ============================================================================\n\n/**\n * The Socket.io room for an entity subscription: `{serviceName}:{entryId}`.\n *\n * Static counterpart to `BaseService.getRoomName` — usable from shared\n * modules and when emitting into *another* service's room without holding\n * its instance.\n */\nexport function serviceRoom(serviceName: string, entryId: string): string {\n return `${serviceName}:${entryId}`;\n}\n\n/**\n * The room subscribers of an elevated (unfiltered) entity tier join in\n * addition to {@link serviceRoom}. Emission targets it via\n * `io.to(fullRoom)` + `io.to(room).except(fullRoom)`.\n */\nexport function serviceFullRoom(serviceName: string, entryId: string): string {\n return `${serviceRoom(serviceName, entryId)}:full`;\n}\n\n/**\n * The room for a collection scope:\n * `{serviceName}:collection:{collection}:{scopeId}`.\n * Entity ids are cuids, so this never collides with {@link serviceRoom}.\n */\nexport function collectionRoom(serviceName: string, collection: string, scopeId: string): string {\n return `${serviceName}:collection:${collection}:${scopeId}`;\n}\n\n/**\n * The event name collection deltas are emitted as. Identical to the room\n * name by design — one event per scope room, no id parsing client-side.\n */\nexport function collectionEventName(\n serviceName: string,\n collection: string,\n scopeId: string,\n): string {\n return collectionRoom(serviceName, collection, scopeId);\n}\n\n/**\n * The per-user room every authenticated socket joins on connection:\n * `user:{userId}`. Used for targeted notifications and adapter-safe\n * revocation (`kickFromCollection`).\n */\nexport function userRoom(userId: string): string {\n return `user:${userId}`;\n}\n\n// ============================================================================\n// Typed Room Events\n// ============================================================================\n\n/**\n * Augmentable map of custom room event names to their payload types.\n * Empty by default — `emitToRoom` and `useRoomEvents` degrade gracefully to\n * `string` names and `unknown` payloads until an app augments it:\n *\n * @example\n * ```typescript\n * // In your app's shared types\n * declare module \"@fitzzero/quickdraw-core\" {\n * interface QuickdrawEventMap {\n * \"chat:memberUpdate\": { chatId: string; userId: string };\n * \"presence:changed\": { userId: string; online: boolean };\n * }\n * }\n * ```\n *\n * Collection deltas and `{service}:update:{id}` events need no entries here —\n * they are framework-generated and typed end-to-end by the service generics.\n */\n// oxlint-disable-next-line typescript/no-empty-interface, typescript/no-empty-object-type\nexport interface QuickdrawEventMap {}\n\n/**\n * Event names accepted by `emitToRoom`/`useRoomEvents`: keys of the\n * augmented {@link QuickdrawEventMap} (with autocomplete) plus any string.\n */\nexport type QuickdrawEventName =\n | (keyof QuickdrawEventMap & string)\n | (string & Record<never, never>);\n\n/**\n * Payload type for a room event: the mapped type when the name is in\n * {@link QuickdrawEventMap}, `unknown` otherwise.\n */\nexport type QuickdrawEventData<E extends string> = E extends keyof QuickdrawEventMap\n ? QuickdrawEventMap[E]\n : unknown;\n\n// ============================================================================\n// Collection Subscription Types (wire protocol)\n// ============================================================================\n\n/**\n * One page of a collection snapshot, returned by a collection's `snapshot`\n * query and by the `{service}:collection:subscribe` ack.\n */\nexport interface CollectionSnapshotPage<TItem> {\n items: TItem[];\n nextCursor: string | null;\n totalCount: number;\n /**\n * Full membership id list for the scope (ids only, cheap select).\n * Returned on cursor-less (first-page / re-snapshot) requests so clients\n * can prune rows deleted while disconnected. Omit for unbounded scopes\n * (e.g. long chat histories) — the client then merges without pruning.\n */\n ids?: string[];\n /**\n * Set by the server when the scope's id list exceeded the cap (5,000) and\n * `ids` was dropped — the client degrades to no-prune merges.\n */\n idsTruncated?: boolean;\n}\n\n/** Snapshot page plus the revision captured before the query ran. */\nexport type CollectionSnapshotResponse<TItem> = CollectionSnapshotPage<TItem> & { rev: number };\n\n/**\n * Payload for `{service}:collection:subscribe`. Cursor-less calls join the\n * scope room and may include `ids`; cursor-bearing calls are pure paging —\n * no room join, no `ids`.\n */\nexport type CollectionSubscribePayload = {\n collection: string;\n scopeId: string;\n cursor?: string | null;\n limit?: number;\n};\n\n/** Payload for `{service}:collection:unsubscribe`. */\nexport type CollectionUnsubscribePayload = {\n collection: string;\n scopeId: string;\n};\n\n/**\n * A collection change, emitted to the scope room under the event name\n * {@link collectionEventName}.\n *\n * `rev` is per-item last-writer-wins: clients ignore an `added`/`updated`\n * older than what they hold for that id, and snapshot items only overwrite\n * cached items whose rev predates the snapshot rev. Revisions are epoch\n * milliseconds (`Date.now()` at emit, or the collection's `revOf`).\n */\nexport type CollectionDelta<TItem extends { id: string }> =\n | { type: \"added\"; item: TItem; rev: number }\n | { type: \"updated\"; item: TItem; rev: number }\n | { type: \"removed\"; id: string; rev: number }\n | { type: \"reset\"; rev: number };\n\n// ============================================================================\n// Utility Types\n// ============================================================================\n\n/**\n * Extracts payload type from a service method definition\n */\nexport type ExtractPayload<T> = T extends { payload: infer P } ? P : never;\n\n/**\n * Extracts response type from a service method definition\n */\nexport type ExtractResponse<T> = T extends { response: infer R } ? R : never;\n\n/**\n * Logger interface compatible with Winston and other loggers\n */\nexport interface Logger {\n info(message: string, meta?: Record<string, unknown>): void;\n warn(message: string, meta?: Record<string, unknown>): void;\n error(message: string, meta?: Record<string, unknown>): void;\n debug(message: string, meta?: Record<string, unknown>): void;\n child(options: Record<string, unknown>): Logger;\n}\n\n/**\n * Default console logger implementation\n */\nexport const consoleLogger: Logger = {\n info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? \"\"),\n warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? \"\"),\n error: (message, meta) => console.error(`[ERROR] ${message}`, meta ?? \"\"),\n debug: (message, meta) => console.debug(`[DEBUG] ${message}`, meta ?? \"\"),\n child: () => consoleLogger,\n};\n"]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Socket, Server } from 'socket.io';
|
|
2
2
|
import { Server as Server$1 } from 'http';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { AccessLevel, ServiceMethodDefinition, ServiceChannelDefinition, Logger, AdminFieldConfig } from './shared/index.js';
|
|
4
|
+
import { AccessLevel, ServiceMethodDefinition, ServiceChannelDefinition, CollectionSubscribePayload, CollectionSnapshotResponse, CollectionUnsubscribePayload, Logger, AdminFieldConfig } from './shared/index.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Extended Socket type with authentication properties
|
|
@@ -9,6 +9,24 @@ import { AccessLevel, ServiceMethodDefinition, ServiceChannelDefinition, Logger,
|
|
|
9
9
|
interface QuickdrawSocket extends Socket {
|
|
10
10
|
userId?: string;
|
|
11
11
|
serviceAccess?: Record<string, AccessLevel>;
|
|
12
|
+
/** Principal type from the structured identity (e.g. "user", "taskToken"). */
|
|
13
|
+
principalType?: string;
|
|
14
|
+
/** Arbitrary verified claims stamped by `authenticate`. */
|
|
15
|
+
claims?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Structured result of socket authentication. Lets `authenticate` express
|
|
19
|
+
* multiple principal types (user JWT, task token, deployment-runner token…)
|
|
20
|
+
* instead of only a userId.
|
|
21
|
+
*
|
|
22
|
+
* Everything is stamped onto the socket: `userId`, `principalType`, `claims`,
|
|
23
|
+
* and `serviceAccess` (when omitted, `loadServiceAccess(userId)` is consulted).
|
|
24
|
+
*/
|
|
25
|
+
interface QuickdrawIdentity {
|
|
26
|
+
userId?: string;
|
|
27
|
+
principalType?: string;
|
|
28
|
+
claims?: Record<string, unknown>;
|
|
29
|
+
serviceAccess?: Record<string, AccessLevel>;
|
|
12
30
|
}
|
|
13
31
|
interface QuickdrawServerOptions {
|
|
14
32
|
port: number;
|
|
@@ -21,9 +39,16 @@ interface QuickdrawServerOptions {
|
|
|
21
39
|
auth?: {
|
|
22
40
|
/**
|
|
23
41
|
* Custom authentication function.
|
|
24
|
-
* Return
|
|
42
|
+
* Return a userId string or a structured {@link QuickdrawIdentity} if
|
|
43
|
+
* authenticated; throw or return undefined to reject.
|
|
44
|
+
*/
|
|
45
|
+
authenticate?: (socket: QuickdrawSocket, auth: Record<string, unknown>) => Promise<string | QuickdrawIdentity | undefined>;
|
|
46
|
+
/**
|
|
47
|
+
* Load service-level access for an authenticated user (e.g. from the
|
|
48
|
+
* user record). Used when the identity result does not carry
|
|
49
|
+
* `serviceAccess` itself. Defaults to empty access.
|
|
25
50
|
*/
|
|
26
|
-
|
|
51
|
+
loadServiceAccess?: (userId: string) => Promise<Record<string, AccessLevel> | null | undefined>;
|
|
27
52
|
};
|
|
28
53
|
logger?: Logger;
|
|
29
54
|
/**
|
|
@@ -60,6 +85,12 @@ interface BaseServiceInstance {
|
|
|
60
85
|
getPublicChannels?: () => ServiceChannelDefinition<unknown>[];
|
|
61
86
|
/** Synchronous in-memory access check for channel messages */
|
|
62
87
|
checkChannelAccess?: (channel: ServiceChannelDefinition<unknown>, socket: QuickdrawSocket, payload: unknown) => boolean;
|
|
88
|
+
/** Handle a collection subscribe (services without collections may omit) */
|
|
89
|
+
subscribeCollection?: (payload: CollectionSubscribePayload, socket: QuickdrawSocket) => Promise<CollectionSnapshotResponse<{
|
|
90
|
+
id: string;
|
|
91
|
+
}> | null>;
|
|
92
|
+
/** Handle a collection unsubscribe (services without collections may omit) */
|
|
93
|
+
unsubscribeCollection?: (payload: CollectionUnsubscribePayload, socket: QuickdrawSocket) => void;
|
|
63
94
|
/** Set Socket.io server instance for room-based broadcasts */
|
|
64
95
|
setIo?: (io: Server) => void;
|
|
65
96
|
}
|
|
@@ -118,34 +149,7 @@ interface InstallAdminMethodsOptions {
|
|
|
118
149
|
/** Override specific field configurations */
|
|
119
150
|
fieldOverrides?: Partial<Record<string, Partial<AdminFieldConfig>>>;
|
|
120
151
|
}
|
|
121
|
-
|
|
122
|
-
page?: number;
|
|
123
|
-
pageSize?: number;
|
|
124
|
-
where?: Record<string, unknown>;
|
|
125
|
-
orderBy?: Record<string, "asc" | "desc">;
|
|
126
|
-
}
|
|
127
|
-
interface AdminListResponse<TEntity> {
|
|
128
|
-
items: TEntity[];
|
|
129
|
-
total: number;
|
|
130
|
-
page: number;
|
|
131
|
-
pageSize: number;
|
|
132
|
-
totalPages: number;
|
|
133
|
-
}
|
|
134
|
-
interface AdminSetACLPayload {
|
|
135
|
-
entryId: string;
|
|
136
|
-
acl: Array<{
|
|
137
|
-
userId: string;
|
|
138
|
-
level: AccessLevel;
|
|
139
|
-
}>;
|
|
140
|
-
}
|
|
141
|
-
interface AdminSubscribersResponse {
|
|
142
|
-
entryId: string;
|
|
143
|
-
subscribers: Array<{
|
|
144
|
-
socketId: string;
|
|
145
|
-
userId: string | null;
|
|
146
|
-
}>;
|
|
147
|
-
count: number;
|
|
148
|
-
}
|
|
152
|
+
|
|
149
153
|
/**
|
|
150
154
|
* Generic Prisma delegate type for CRUD operations.
|
|
151
155
|
* This allows BaseService to work with any Prisma model without tight coupling.
|
|
@@ -178,4 +182,4 @@ interface PrismaDelegate<TEntity, TCreateInput, TUpdateInput, TWhereUniqueInput
|
|
|
178
182
|
}) => Promise<number>;
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
export type {
|
|
185
|
+
export type { BaseServiceInstance as B, InstallAdminMethodsOptions as I, PrismaDelegate as P, QuickdrawServerResult as Q, ServiceRegistryInstance as S, QuickdrawSocket as a, BaseServiceOptions as b, QuickdrawServerOptions as c, QuickdrawIdentity as d };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Socket } from 'socket.io-client';
|
|
2
2
|
import { QueryClient } from '@tanstack/react-query';
|
|
3
|
-
import { AccessLevel, ServiceResponse } from './shared/index.js';
|
|
3
|
+
import { AccessLevel, ServiceResponse, CollectionDelta, QuickdrawEventMap } from './shared/index.js';
|
|
4
4
|
|
|
5
5
|
interface QuickdrawProviderProps {
|
|
6
6
|
children: React.ReactNode;
|
|
@@ -34,6 +34,18 @@ interface QuickdrawProviderProps {
|
|
|
34
34
|
* must connect with path "/.proxy/api/socket.io".
|
|
35
35
|
*/
|
|
36
36
|
socketPath?: string;
|
|
37
|
+
/**
|
|
38
|
+
* What to do with TanStack Query caches when the socket reconnects.
|
|
39
|
+
*
|
|
40
|
+
* Subscriptions and collections heal themselves on reconnect
|
|
41
|
+
* (re-subscribe / re-snapshot), but plain `useServiceQuery` reads keep
|
|
42
|
+
* stale data — room events emitted during the outage are gone for good.
|
|
43
|
+
* `"invalidate-queries"` (the default) invalidates all queries on
|
|
44
|
+
* reconnect so active reads refetch. Set `"none"` to opt out.
|
|
45
|
+
*
|
|
46
|
+
* @default "invalidate-queries"
|
|
47
|
+
*/
|
|
48
|
+
reconnectBehavior?: "invalidate-queries" | "none";
|
|
37
49
|
}
|
|
38
50
|
/**
|
|
39
51
|
* Return type for the useChannelSend hook.
|
|
@@ -133,6 +145,57 @@ interface UseRoomEventsOptions {
|
|
|
133
145
|
*/
|
|
134
146
|
enabled?: boolean;
|
|
135
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Handler map for useRoomEvents, typed via the augmentable
|
|
150
|
+
* `QuickdrawEventMap`: known event names get their mapped payload type
|
|
151
|
+
* (with autocomplete), unknown names degrade to the untyped handler shape.
|
|
152
|
+
*/
|
|
153
|
+
type QuickdrawRoomEventHandlers = {
|
|
154
|
+
[E in keyof QuickdrawEventMap]?: (data: QuickdrawEventMap[E]) => void;
|
|
155
|
+
} & {
|
|
156
|
+
[event: string]: ((data: never) => void) | undefined;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Options for the useCollection hook.
|
|
160
|
+
*/
|
|
161
|
+
interface UseCollectionOptions<TItem extends {
|
|
162
|
+
id: string;
|
|
163
|
+
}> {
|
|
164
|
+
/** Whether to subscribe (default: true). */
|
|
165
|
+
enabled?: boolean;
|
|
166
|
+
/** Snapshot page size. Default comes from the server definition. */
|
|
167
|
+
limit?: number;
|
|
168
|
+
/**
|
|
169
|
+
* Client-side ordering. Omit to keep server snapshot order (new `added`
|
|
170
|
+
* items land per `insertPosition`). Should be referentially stable.
|
|
171
|
+
*/
|
|
172
|
+
compare?: (a: TItem, b: TItem) => number;
|
|
173
|
+
/** Where `added` items land when no `compare` is set. Default "end". */
|
|
174
|
+
insertPosition?: "start" | "end";
|
|
175
|
+
/** Called for every delta received (informational; cache updates are automatic). */
|
|
176
|
+
onDelta?: (delta: CollectionDelta<TItem>) => void;
|
|
177
|
+
/** Called when a snapshot or page request fails. */
|
|
178
|
+
onError?: (error: string) => void;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Return type for the useCollection hook.
|
|
182
|
+
*/
|
|
183
|
+
interface UseCollectionResult<TItem> {
|
|
184
|
+
/** Ordered, deduped items of the scope. */
|
|
185
|
+
items: TItem[];
|
|
186
|
+
byId: ReadonlyMap<string, TItem>;
|
|
187
|
+
/** Server-reported scope size (null before the first snapshot). */
|
|
188
|
+
totalCount: number | null;
|
|
189
|
+
isLoading: boolean;
|
|
190
|
+
isError: boolean;
|
|
191
|
+
error: string | null;
|
|
192
|
+
hasMore: boolean;
|
|
193
|
+
isLoadingMore: boolean;
|
|
194
|
+
/** Fetch the next page (cursor reuse of the subscribe event). */
|
|
195
|
+
loadMore: () => Promise<void>;
|
|
196
|
+
/** Manual re-snapshot (also what `reset` deltas trigger, debounced 100ms). */
|
|
197
|
+
refresh: () => Promise<void>;
|
|
198
|
+
}
|
|
136
199
|
/**
|
|
137
200
|
* Options for the useServiceQuery hook.
|
|
138
201
|
*/
|
|
@@ -201,6 +264,16 @@ interface UseServiceQueryOptions<TResponse> {
|
|
|
201
264
|
* ```
|
|
202
265
|
*/
|
|
203
266
|
invalidateOn?: string[];
|
|
267
|
+
/**
|
|
268
|
+
* Refetch on a fixed interval (milliseconds), passed through to TanStack
|
|
269
|
+
* Query. `false`/undefined disables interval refetching.
|
|
270
|
+
*/
|
|
271
|
+
refetchInterval?: number | false;
|
|
272
|
+
/**
|
|
273
|
+
* Continue interval refetching while the tab is in the background.
|
|
274
|
+
* Only meaningful with `refetchInterval`.
|
|
275
|
+
*/
|
|
276
|
+
refetchIntervalInBackground?: boolean;
|
|
204
277
|
}
|
|
205
278
|
/**
|
|
206
279
|
* Return type for the useServiceQuery hook.
|
|
@@ -258,6 +331,13 @@ type SubscriptionDataMap<T extends Record<string, unknown>> = T;
|
|
|
258
331
|
interface SubscriptionEntry {
|
|
259
332
|
refCount: number;
|
|
260
333
|
cleanup?: () => void;
|
|
334
|
+
/**
|
|
335
|
+
* Shared per-subscription state slot. The first acquirer (the owner) may
|
|
336
|
+
* stash a controller object here so later acquirers of the same key can
|
|
337
|
+
* drive the shared pipeline (used by useCollection for snapshot/loadMore
|
|
338
|
+
* dedup across components).
|
|
339
|
+
*/
|
|
340
|
+
controller?: unknown;
|
|
261
341
|
}
|
|
262
342
|
/**
|
|
263
343
|
* Registry for tracking active subscriptions per socket instance.
|
|
@@ -313,6 +393,18 @@ interface QuickdrawSocketContextValue {
|
|
|
313
393
|
disconnect: () => void;
|
|
314
394
|
subscriptionRegistry: SubscriptionRegistry;
|
|
315
395
|
subscriptionBatcher: SubscriptionBatcher;
|
|
396
|
+
/**
|
|
397
|
+
* True while the server has reported this client rate-limited.
|
|
398
|
+
* `useServiceQuery` pauses reads while set — rejected events still count
|
|
399
|
+
* against the budget and would re-trip the limiter indefinitely.
|
|
400
|
+
*/
|
|
401
|
+
isRateLimited: boolean;
|
|
402
|
+
/**
|
|
403
|
+
* Report a rate-limit rejection (used internally by hooks; callable by
|
|
404
|
+
* app code that talks to the socket directly). Pauses reads for
|
|
405
|
+
* `retryAfterMs` (or a 5s default when the server didn't say).
|
|
406
|
+
*/
|
|
407
|
+
reportRateLimited: (retryAfterMs?: number) => void;
|
|
316
408
|
}
|
|
317
409
|
|
|
318
|
-
export type { ClientServiceMethodMap as C, QuickdrawSocketContextValue as Q, SubscriptionDataMap as S, UseServiceOptions as U, QuickdrawProviderProps as a, UseServiceResult as b, UseServiceQueryOptions as c, UseServiceQueryResult as d, UseSubscriptionOptions as e, UseSubscriptionResult as f,
|
|
410
|
+
export type { ClientServiceMethodMap as C, QuickdrawSocketContextValue as Q, SubscriptionDataMap as S, UseServiceOptions as U, QuickdrawProviderProps as a, UseServiceResult as b, UseServiceQueryOptions as c, UseServiceQueryResult as d, UseSubscriptionOptions as e, UseSubscriptionResult as f, UseCollectionOptions as g, UseCollectionResult as h, QuickdrawRoomEventHandlers as i, UseRoomEventsOptions as j, UseChannelSendResult as k };
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import noCrossServiceMutations from "./rules/no-cross-service-mutations.mjs";
|
|
2
|
+
import noDirectPrismaMutations from "./rules/no-direct-prisma-mutations.mjs";
|
|
2
3
|
import requireZodSchema from "./rules/require-zod-schema.mjs";
|
|
3
4
|
import noServiceMethodRecord from "./rules/no-service-method-record.mjs";
|
|
4
5
|
import noUnsafePayloadCast from "./rules/no-unsafe-payload-cast.mjs";
|
|
6
|
+
import noManualCollectionEvents from "./rules/no-manual-collection-events.mjs";
|
|
5
7
|
import noRawSocketOn from "./rules/no-raw-socket-on.mjs";
|
|
6
8
|
import noRawSocketEmit from "./rules/no-raw-socket-emit.mjs";
|
|
7
9
|
import noRawServiceRoomString from "./rules/no-raw-service-room-string.mjs";
|
|
@@ -13,9 +15,11 @@ import noRawTypographyStrings from "./rules/no-raw-typography-strings.mjs";
|
|
|
13
15
|
export default {
|
|
14
16
|
rules: {
|
|
15
17
|
"no-cross-service-mutations": noCrossServiceMutations,
|
|
18
|
+
"no-direct-prisma-mutations": noDirectPrismaMutations,
|
|
16
19
|
"require-zod-schema": requireZodSchema,
|
|
17
20
|
"no-service-method-record": noServiceMethodRecord,
|
|
18
21
|
"no-unsafe-payload-cast": noUnsafePayloadCast,
|
|
22
|
+
"no-manual-collection-events": noManualCollectionEvents,
|
|
19
23
|
"no-raw-socket-on": noRawSocketOn,
|
|
20
24
|
"no-raw-socket-emit": noRawSocketEmit,
|
|
21
25
|
"no-raw-service-room-string": noRawServiceRoomString,
|