@fitzzero/quickdraw-core 3.9.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -196,68 +196,61 @@ declare const CHANNEL_EVENT_PREFIX = "channel:";
196
196
  type AdminListPayload = {
197
197
  page?: number;
198
198
  pageSize?: number;
199
- sort?: {
200
- field?: string;
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
- rows: T[];
203
+ items: T[];
204
+ total: number;
214
205
  page: number;
215
206
  pageSize: number;
216
- total: number;
207
+ totalPages: number;
217
208
  };
218
209
  type AdminGetPayload = {
219
210
  id: string;
220
211
  };
221
- type AdminCreatePayload<TInsert> = {
222
- data: Partial<TInsert>;
212
+ type AdminCreatePayload<TCreate> = {
213
+ data: TCreate;
223
214
  };
224
- type AdminUpdatePayload<TInsert> = {
215
+ type AdminUpdatePayload<TUpdate> = {
225
216
  id: string;
226
- data: Partial<TInsert>;
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 AdminSetEntryACLPayload = {
236
- id: string;
226
+ type AdminSetACLPayload = {
227
+ entryId: string;
237
228
  acl: ACL;
238
229
  };
239
230
  type AdminGetSubscribersPayload = {
240
- id: string;
231
+ entryId: string;
241
232
  };
242
- type AdminGetSubscribersResponse = {
243
- id: string;
233
+ type AdminSubscribersResponse = {
234
+ entryId: string;
244
235
  subscribers: Array<{
245
236
  socketId: string;
246
- userId?: string;
237
+ userId: string | null;
247
238
  }>;
239
+ count: number;
248
240
  };
249
241
  type AdminReemitPayload = {
250
- id: string;
242
+ entryId: string;
251
243
  };
252
244
  type AdminReemitResponse = {
253
- emitted: boolean;
245
+ success: boolean;
246
+ subscriberCount: number;
254
247
  };
255
248
  type AdminUnsubscribeAllPayload = {
256
- id: string;
249
+ entryId: string;
257
250
  };
258
251
  type AdminUnsubscribeAllResponse = {
259
- id: string;
260
- unsubscribed: number;
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 AdminGetSubscribersResponse, type AdminListPayload, type AdminListResponse, type AdminMetaPayload, type AdminMetaResponse, type AdminReemitPayload, type AdminReemitResponse, type AdminServiceMeta, type AdminSetEntryACLPayload, type AdminUnsubscribeAllPayload, type AdminUnsubscribeAllResponse, type AdminUpdatePayload, CHANNEL_EVENT_PREFIX, type ExtractPayload, type ExtractResponse, type Logger, type QuickdrawUser, type ServiceChannelContext, type ServiceChannelDefinition, type ServiceChannelMap, type ServiceMethodContext, type ServiceMethodDefinition, type ServiceMethodMap, type ServiceResponse, type SubscribePayload, type UnsubscribePayload, channelEventName, consoleLogger };
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 };
@@ -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
@@ -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 the userId if authenticated, or throw/return undefined to reject.
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
- authenticate?: (socket: QuickdrawSocket, auth: Record<string, unknown>) => Promise<string | undefined>;
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
- interface AdminListPayload {
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 { AdminListPayload as A, BaseServiceInstance as B, InstallAdminMethodsOptions as I, PrismaDelegate as P, QuickdrawServerResult as Q, ServiceRegistryInstance as S, QuickdrawSocket as a, BaseServiceOptions as b, AdminListResponse as c, AdminSetACLPayload as d, AdminSubscribersResponse as e, QuickdrawServerOptions as f };
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,28 @@ 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";
49
+ /**
50
+ * Socket.io transports to negotiate, in order.
51
+ * The default matches socket.io's browser behavior. React Native clients
52
+ * should pass `["websocket"]` — the polling fallback assumes browser XHR
53
+ * semantics and adds no value there.
54
+ * Applies when the socket is (re)created; changing it alone does not
55
+ * reconnect an existing socket.
56
+ * @default ["websocket", "polling"]
57
+ */
58
+ transports?: Array<"websocket" | "polling">;
37
59
  }
38
60
  /**
39
61
  * Return type for the useChannelSend hook.
@@ -133,6 +155,57 @@ interface UseRoomEventsOptions {
133
155
  */
134
156
  enabled?: boolean;
135
157
  }
158
+ /**
159
+ * Handler map for useRoomEvents, typed via the augmentable
160
+ * `QuickdrawEventMap`: known event names get their mapped payload type
161
+ * (with autocomplete), unknown names degrade to the untyped handler shape.
162
+ */
163
+ type QuickdrawRoomEventHandlers = {
164
+ [E in keyof QuickdrawEventMap]?: (data: QuickdrawEventMap[E]) => void;
165
+ } & {
166
+ [event: string]: ((data: never) => void) | undefined;
167
+ };
168
+ /**
169
+ * Options for the useCollection hook.
170
+ */
171
+ interface UseCollectionOptions<TItem extends {
172
+ id: string;
173
+ }> {
174
+ /** Whether to subscribe (default: true). */
175
+ enabled?: boolean;
176
+ /** Snapshot page size. Default comes from the server definition. */
177
+ limit?: number;
178
+ /**
179
+ * Client-side ordering. Omit to keep server snapshot order (new `added`
180
+ * items land per `insertPosition`). Should be referentially stable.
181
+ */
182
+ compare?: (a: TItem, b: TItem) => number;
183
+ /** Where `added` items land when no `compare` is set. Default "end". */
184
+ insertPosition?: "start" | "end";
185
+ /** Called for every delta received (informational; cache updates are automatic). */
186
+ onDelta?: (delta: CollectionDelta<TItem>) => void;
187
+ /** Called when a snapshot or page request fails. */
188
+ onError?: (error: string) => void;
189
+ }
190
+ /**
191
+ * Return type for the useCollection hook.
192
+ */
193
+ interface UseCollectionResult<TItem> {
194
+ /** Ordered, deduped items of the scope. */
195
+ items: TItem[];
196
+ byId: ReadonlyMap<string, TItem>;
197
+ /** Server-reported scope size (null before the first snapshot). */
198
+ totalCount: number | null;
199
+ isLoading: boolean;
200
+ isError: boolean;
201
+ error: string | null;
202
+ hasMore: boolean;
203
+ isLoadingMore: boolean;
204
+ /** Fetch the next page (cursor reuse of the subscribe event). */
205
+ loadMore: () => Promise<void>;
206
+ /** Manual re-snapshot (also what `reset` deltas trigger, debounced 100ms). */
207
+ refresh: () => Promise<void>;
208
+ }
136
209
  /**
137
210
  * Options for the useServiceQuery hook.
138
211
  */
@@ -201,6 +274,16 @@ interface UseServiceQueryOptions<TResponse> {
201
274
  * ```
202
275
  */
203
276
  invalidateOn?: string[];
277
+ /**
278
+ * Refetch on a fixed interval (milliseconds), passed through to TanStack
279
+ * Query. `false`/undefined disables interval refetching.
280
+ */
281
+ refetchInterval?: number | false;
282
+ /**
283
+ * Continue interval refetching while the tab is in the background.
284
+ * Only meaningful with `refetchInterval`.
285
+ */
286
+ refetchIntervalInBackground?: boolean;
204
287
  }
205
288
  /**
206
289
  * Return type for the useServiceQuery hook.
@@ -258,6 +341,13 @@ type SubscriptionDataMap<T extends Record<string, unknown>> = T;
258
341
  interface SubscriptionEntry {
259
342
  refCount: number;
260
343
  cleanup?: () => void;
344
+ /**
345
+ * Shared per-subscription state slot. The first acquirer (the owner) may
346
+ * stash a controller object here so later acquirers of the same key can
347
+ * drive the shared pipeline (used by useCollection for snapshot/loadMore
348
+ * dedup across components).
349
+ */
350
+ controller?: unknown;
261
351
  }
262
352
  /**
263
353
  * Registry for tracking active subscriptions per socket instance.
@@ -313,6 +403,18 @@ interface QuickdrawSocketContextValue {
313
403
  disconnect: () => void;
314
404
  subscriptionRegistry: SubscriptionRegistry;
315
405
  subscriptionBatcher: SubscriptionBatcher;
406
+ /**
407
+ * True while the server has reported this client rate-limited.
408
+ * `useServiceQuery` pauses reads while set — rejected events still count
409
+ * against the budget and would re-trip the limiter indefinitely.
410
+ */
411
+ isRateLimited: boolean;
412
+ /**
413
+ * Report a rate-limit rejection (used internally by hooks; callable by
414
+ * app code that talks to the socket directly). Pauses reads for
415
+ * `retryAfterMs` (or a 5s default when the server didn't say).
416
+ */
417
+ reportRateLimited: (retryAfterMs?: number) => void;
316
418
  }
317
419
 
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, UseRoomEventsOptions as g, UseChannelSendResult as h };
420
+ 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,