@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/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Fast fullstack patterns for real-time applications with Socket.io and TanStack Q
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Server Core**: BaseService class with typed CRUD, ACL-based access control, and real-time subscriptions
|
|
8
|
+
- **Collections**: declare a scope-keyed list once, get live add/update/remove deltas, pagination, and reconnect healing — no hand-wired events
|
|
8
9
|
- **Client Core**: TanStack Query integration with Socket.io for real-time state management
|
|
9
10
|
- **Socket Inputs**: Pre-built form components that sync with server state
|
|
10
11
|
- **Custom OAuth**: JWT-based authentication with Discord and Google providers
|
|
@@ -110,6 +111,88 @@ function ChatPage({ chatId }: { chatId: string }) {
|
|
|
110
111
|
}
|
|
111
112
|
```
|
|
112
113
|
|
|
114
|
+
### Collections (Live Lists)
|
|
115
|
+
|
|
116
|
+
Entity subscriptions cover single rows; **collections** cover lists. A
|
|
117
|
+
collection is "rows of this service, grouped by a scope id derived from the
|
|
118
|
+
row" — declare it once and the framework handles emission (multi-node-safe),
|
|
119
|
+
pagination, and reconnect correctness. No more `task:created` /
|
|
120
|
+
`task:deleted` mirror events, `invalidateOn` refetches, or hand-rolled
|
|
121
|
+
merge-by-id state.
|
|
122
|
+
|
|
123
|
+
**Server** — declare next to your methods; the CRUD trio emits deltas
|
|
124
|
+
automatically (scope moves and predicate entry/exit included):
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
type MessageCollections = { byChat: { item: MessageDTO } };
|
|
128
|
+
|
|
129
|
+
class MessageService extends BaseService<
|
|
130
|
+
Message,
|
|
131
|
+
Prisma.MessageCreateInput,
|
|
132
|
+
Prisma.MessageUpdateInput,
|
|
133
|
+
MessageServiceMethods,
|
|
134
|
+
Record<string, unknown>, // channels
|
|
135
|
+
MessageDTO, // TDto — wire shape
|
|
136
|
+
MessageCollections // TCollections
|
|
137
|
+
> {
|
|
138
|
+
constructor(prisma: PrismaClient) {
|
|
139
|
+
super({ serviceName: "messageService" });
|
|
140
|
+
this.setDelegate(prisma.message);
|
|
141
|
+
|
|
142
|
+
this.defineCollection("byChat", {
|
|
143
|
+
resolveScopeId: (message) => message.chatId,
|
|
144
|
+
checkScopeAccess: (userId, chatId) => this.isChatMember(userId, chatId),
|
|
145
|
+
// Server-ordered first page + reconnect re-snapshot. Omit `ids` for
|
|
146
|
+
// unbounded histories like this one; return it for bounded scopes so
|
|
147
|
+
// reconnecting clients prune rows deleted while offline.
|
|
148
|
+
snapshot: async (chatId, { cursor, limit }) => this.getMessagePage(chatId, cursor, limit),
|
|
149
|
+
toItem: (message) => this.toDto(message),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Client** — one hook per list; live deltas, `loadMore` paging, and
|
|
156
|
+
re-snapshot-on-reconnect are built in:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { useCollection } from "@fitzzero/quickdraw-core/client";
|
|
160
|
+
|
|
161
|
+
function ChatWindow({ chatId }: { chatId: string }) {
|
|
162
|
+
const {
|
|
163
|
+
items: messages,
|
|
164
|
+
isLoading,
|
|
165
|
+
hasMore,
|
|
166
|
+
loadMore,
|
|
167
|
+
} = useCollection<MessageDTO>("messageService", "byChat", chatId, {
|
|
168
|
+
compare: (a, b) => a.createdAt.localeCompare(b.createdAt),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return <MessageList messages={messages} onScrollTop={hasMore ? loadMore : undefined} />;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Scopes don't have to be parent entities — `resolveScopeId` may return a
|
|
176
|
+
`string[]` to fan out (e.g. a chat appearing in every member's `myChats`
|
|
177
|
+
collection, scope = user id), or `null` to exclude a row (predicate
|
|
178
|
+
filtering). For hand-rolled write paths, one-line choke points keep deltas
|
|
179
|
+
flowing: `emitCollectionUpsert` / `emitCollectionRemove` /
|
|
180
|
+
`emitCollectionMove` / `emitCollectionReset`, plus `kickFromCollection` for
|
|
181
|
+
adapter-safe ACL revocation.
|
|
182
|
+
|
|
183
|
+
ACL is deliberately simple: items are **scope-visible** — anyone who passes
|
|
184
|
+
`checkScopeAccess` sees every item in full (strip sensitive fields in
|
|
185
|
+
`toItem`/`snapshot`). If visibility varies per user within a scope, that's
|
|
186
|
+
not a collection — use separate scopes or entity subscriptions.
|
|
187
|
+
|
|
188
|
+
When to use which:
|
|
189
|
+
|
|
190
|
+
| Hook | Use for |
|
|
191
|
+
| ----------------- | ------------------------------------------------------------- |
|
|
192
|
+
| `useSubscription` | One entity, field-tiered (detail panels) |
|
|
193
|
+
| `useCollection` | Live lists of a scope (boards, feeds, chat histories) |
|
|
194
|
+
| `useServiceQuery` | Genuinely query-shaped reads (search, cross-scope aggregates) |
|
|
195
|
+
|
|
113
196
|
### Socket Inputs
|
|
114
197
|
|
|
115
198
|
```tsx
|
|
@@ -131,54 +214,71 @@ function ChatTitleEditor({ chat, updateChat }) {
|
|
|
131
214
|
|
|
132
215
|
### Custom Room Events
|
|
133
216
|
|
|
134
|
-
|
|
217
|
+
For genuinely custom, ephemeral events (typing indicators, presence pulses —
|
|
218
|
+
things that aren't rows), broadcast with `emitToRoom` and listen with
|
|
219
|
+
`useRoomEvents`:
|
|
135
220
|
|
|
136
221
|
```tsx
|
|
137
222
|
import { useSubscription, useRoomEvents } from "@fitzzero/quickdraw-core/client";
|
|
138
223
|
|
|
139
|
-
function
|
|
140
|
-
const { data:
|
|
141
|
-
const [
|
|
224
|
+
function ChatView({ chatId }: { chatId: string }) {
|
|
225
|
+
const { data: chat } = useSubscription("chatService", chatId);
|
|
226
|
+
const [typing, setTyping] = useState<string | null>(null);
|
|
142
227
|
|
|
143
228
|
// Lifecycle-managed event listeners — cleanup handled automatically
|
|
144
229
|
useRoomEvents({
|
|
145
|
-
"
|
|
146
|
-
"
|
|
230
|
+
"chat:typing": ({ userName }: { userName: string }) => setTyping(userName),
|
|
231
|
+
"chat:typingStop": () => setTyping(null),
|
|
147
232
|
});
|
|
148
233
|
|
|
149
|
-
return <
|
|
234
|
+
return <Chat chat={chat} typing={typing} />;
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Event names and payloads can be typed end-to-end by augmenting
|
|
239
|
+
`QuickdrawEventMap` from the package root — `emitToRoom` and `useRoomEvents`
|
|
240
|
+
then check payloads and autocomplete names (and degrade to
|
|
241
|
+
`string`/`unknown` if you never augment it):
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
declare module "@fitzzero/quickdraw-core" {
|
|
245
|
+
interface QuickdrawEventMap {
|
|
246
|
+
"chat:typing": { userName: string };
|
|
247
|
+
"chat:typingStop": Record<string, never>;
|
|
248
|
+
}
|
|
150
249
|
}
|
|
151
250
|
```
|
|
152
251
|
|
|
252
|
+
Don't hand-emit row lifecycle events (`task:created`, `task:deleted`, …) —
|
|
253
|
+
that's what collections automate; the shipped
|
|
254
|
+
`quickdraw/no-manual-collection-events` lint rule flags them.
|
|
255
|
+
|
|
153
256
|
### Auto-Invalidating Queries
|
|
154
257
|
|
|
155
|
-
For
|
|
258
|
+
For genuinely query-shaped reads (search results, cross-scope aggregates)
|
|
259
|
+
that should refresh when related events fire, use `invalidateOn`:
|
|
156
260
|
|
|
157
261
|
```tsx
|
|
158
262
|
import { useServiceQuery } from "@fitzzero/quickdraw-core/client";
|
|
159
263
|
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
const { data: tasks } = useServiceQuery(
|
|
264
|
+
function SearchResults({ query }: { query: string }) {
|
|
265
|
+
const { data: results } = useServiceQuery(
|
|
163
266
|
"taskService",
|
|
164
|
-
"
|
|
165
|
-
{
|
|
267
|
+
"searchTasks",
|
|
268
|
+
{ query },
|
|
166
269
|
{
|
|
167
|
-
invalidateOn: ["task:
|
|
270
|
+
invalidateOn: ["task:statusUpdate"],
|
|
271
|
+
refetchInterval: 60_000, // optional periodic refresh
|
|
168
272
|
},
|
|
169
273
|
);
|
|
170
274
|
|
|
171
|
-
return
|
|
172
|
-
<ul>
|
|
173
|
-
{tasks?.map((t) => (
|
|
174
|
-
<li key={t.id}>{t.title}</li>
|
|
175
|
-
))}
|
|
176
|
-
</ul>
|
|
177
|
-
);
|
|
275
|
+
return <Results items={results} />;
|
|
178
276
|
}
|
|
179
277
|
```
|
|
180
278
|
|
|
181
|
-
Rapid-fire events within 100ms are debounced into a single refetch.
|
|
279
|
+
Rapid-fire events within 100ms are debounced into a single refetch. For
|
|
280
|
+
plain scope lists, prefer `useCollection` — it replaces the
|
|
281
|
+
`invalidateOn` + refetch cycle with true deltas.
|
|
182
282
|
|
|
183
283
|
### Channels (High-Frequency Traffic)
|
|
184
284
|
|
|
@@ -285,17 +385,89 @@ When to use which:
|
|
|
285
385
|
| ACL | full async check incl. DB | in-memory only |
|
|
286
386
|
| Rate limit | global limiter | per-channel token bucket |
|
|
287
387
|
|
|
388
|
+
## Splitting Large Services
|
|
389
|
+
|
|
390
|
+
Real services grow past what one file should hold. The proven pattern —
|
|
391
|
+
battle-tested in the framework's largest consumer without import cycles — is
|
|
392
|
+
an abstract `*ServiceCore` plus method modules wired by a thin concrete
|
|
393
|
+
subclass:
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
// services/task/service-core.ts — state, ACL overrides, helpers. No methods.
|
|
397
|
+
export abstract class TaskServiceCore extends BaseService<
|
|
398
|
+
Task,
|
|
399
|
+
Prisma.TaskCreateInput,
|
|
400
|
+
Prisma.TaskUpdateInput,
|
|
401
|
+
TaskServiceMethods,
|
|
402
|
+
TaskChannels,
|
|
403
|
+
TaskDTO,
|
|
404
|
+
TaskCollections
|
|
405
|
+
> {
|
|
406
|
+
constructor(protected readonly prisma: PrismaClient) {
|
|
407
|
+
super({ serviceName: "taskService" });
|
|
408
|
+
this.setDelegate(prisma.task);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
public buildCardDTO(taskId: string): Promise<TaskCardDTO> {
|
|
412
|
+
/* ... */
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// services/task/methods/create-task.ts — one module per method (or cluster).
|
|
417
|
+
// defineMethod is public precisely so modules can register on the instance.
|
|
418
|
+
export function registerCreateTask(service: TaskService): void {
|
|
419
|
+
service.defineMethod(
|
|
420
|
+
"createTask",
|
|
421
|
+
"Read",
|
|
422
|
+
async (payload, ctx) => {
|
|
423
|
+
// ...
|
|
424
|
+
},
|
|
425
|
+
{ schema: createTaskSchema },
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// services/task/index.ts — the concrete subclass wires the modules.
|
|
430
|
+
export class TaskService extends TaskServiceCore {
|
|
431
|
+
constructor(prisma: PrismaClient) {
|
|
432
|
+
super(prisma);
|
|
433
|
+
registerCreateTask(this);
|
|
434
|
+
registerUpdateTask(this);
|
|
435
|
+
// ...
|
|
436
|
+
this.verifyAllMethods(["createTask", "updateTask" /* ... */]);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Core → modules → concrete class is a DAG: the core never imports the modules,
|
|
442
|
+
the modules never import each other. `verifyAllMethods` catches a forgotten
|
|
443
|
+
`register*` call at boot. The public choke points (`emitUpdate`,
|
|
444
|
+
`emitCollectionUpsert`, `emitToRoom`, `isLevelSufficient`, …) exist so method
|
|
445
|
+
modules outside the class stay fully capable.
|
|
446
|
+
|
|
288
447
|
## Package Exports
|
|
289
448
|
|
|
290
449
|
```typescript
|
|
291
450
|
// Shared types (both server and client)
|
|
292
|
-
import {
|
|
451
|
+
import {
|
|
452
|
+
ServiceResponse,
|
|
453
|
+
AccessLevel,
|
|
454
|
+
ServiceMethodMap,
|
|
455
|
+
// Room helpers + typed events (4.0)
|
|
456
|
+
serviceRoom,
|
|
457
|
+
collectionRoom,
|
|
458
|
+
userRoom,
|
|
459
|
+
type QuickdrawEventMap,
|
|
460
|
+
type CollectionDelta,
|
|
461
|
+
} from "@fitzzero/quickdraw-core";
|
|
293
462
|
|
|
294
463
|
// Server
|
|
295
464
|
import {
|
|
296
465
|
BaseService,
|
|
466
|
+
BaseRpcService, // 4.0: method-only services, no delegate/CRUD
|
|
297
467
|
ServiceRegistry,
|
|
298
468
|
createQuickdrawServer,
|
|
469
|
+
type CollectionDefinition, // 4.0
|
|
470
|
+
type QuickdrawIdentity, // 4.0: structured authenticate result
|
|
299
471
|
createJWT,
|
|
300
472
|
verifyJWT,
|
|
301
473
|
discordProvider,
|
|
@@ -340,7 +512,9 @@ import {
|
|
|
340
512
|
useService,
|
|
341
513
|
useServiceQuery,
|
|
342
514
|
useSubscription,
|
|
515
|
+
useCollection, // 4.0: live scope-keyed lists
|
|
343
516
|
useRoomEvents,
|
|
517
|
+
ServiceCallError, // 4.0: hook errors carry the server code
|
|
344
518
|
SocketCheckbox,
|
|
345
519
|
SocketTextField,
|
|
346
520
|
SocketSelect,
|
|
@@ -436,6 +610,7 @@ export type ChatServiceMethods = ServiceMethodMap<{
|
|
|
436
610
|
│ │
|
|
437
611
|
│ useService() ──────────────────────────────────────────────┐ │
|
|
438
612
|
│ useSubscription() ─────────────────────────────────────────┤ │
|
|
613
|
+
│ useCollection() ───────────────────────────────────────────┤ │
|
|
439
614
|
│ SocketTextField, SocketCheckbox, ... ──────────────────────┤ │
|
|
440
615
|
│ │ │
|
|
441
616
|
└─────────────────────────────────────────────────────────────│───┘
|
|
@@ -450,10 +625,11 @@ export type ChatServiceMethods = ServiceMethodMap<{
|
|
|
450
625
|
│ ├── Auto-discovers public methods │
|
|
451
626
|
│ └── Wires methods to Socket.io events │
|
|
452
627
|
│ │
|
|
453
|
-
│ BaseService<Entity,
|
|
628
|
+
│ BaseService<Entity, Create, Update, Methods, …, Dto, Colls> │
|
|
454
629
|
│ ├── defineMethod() - Type-safe method definition │
|
|
630
|
+
│ ├── defineCollection() - Live lists with automatic deltas │
|
|
455
631
|
│ ├── subscribe() / unsubscribe() - Real-time subscriptions │
|
|
456
|
-
│ ├── create() / update() / delete() - CRUD
|
|
632
|
+
│ ├── create() / update() / delete() - CRUD, auto-emit + hooks │
|
|
457
633
|
│ └── checkAccess() - ACL enforcement │
|
|
458
634
|
│ │
|
|
459
635
|
│ Auth Utilities │
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseServiceQueryOptions, d as UseServiceQueryResult, e as UseSubscriptionOptions, f as UseSubscriptionResult, g as
|
|
3
|
-
export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-
|
|
2
|
+
import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseServiceQueryOptions, d as UseServiceQueryResult, e as UseSubscriptionOptions, f as UseSubscriptionResult, g as UseCollectionOptions, h as UseCollectionResult, i as QuickdrawRoomEventHandlers, j as UseRoomEventsOptions, k as UseChannelSendResult } from '../types-CX5LuC_8.js';
|
|
3
|
+
export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-CX5LuC_8.js';
|
|
4
|
+
import { CollectionSnapshotResponse, CollectionDelta } from '../shared/index.js';
|
|
4
5
|
import 'socket.io-client';
|
|
5
6
|
import '@tanstack/react-query';
|
|
6
|
-
import '../shared/index.js';
|
|
7
7
|
import 'zod';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -28,7 +28,7 @@ declare function useQuickdrawSocket(): QuickdrawSocketContextValue;
|
|
|
28
28
|
* }
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
|
-
declare function QuickdrawProvider({ children, serverUrl, queryClient, authToken, autoConnect, withCredentials, socketPath, }: QuickdrawProviderProps): React.ReactElement;
|
|
31
|
+
declare function QuickdrawProvider({ children, serverUrl, queryClient, authToken, autoConnect, withCredentials, socketPath, reconnectBehavior, }: QuickdrawProviderProps): React.ReactElement;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Hook for invoking service methods via Socket.io with TanStack Query integration.
|
|
@@ -171,6 +171,37 @@ declare function useSubscription<TData extends {
|
|
|
171
171
|
id: string;
|
|
172
172
|
}>(serviceName: string, entryId: string | null, options?: UseSubscriptionOptions<TData>): UseSubscriptionResult<TData>;
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Hook for subscribing to a server-declared collection: a scope-keyed,
|
|
176
|
+
* live-updating list (`defineCollection` server-side).
|
|
177
|
+
*
|
|
178
|
+
* The initial snapshot arrives via `{service}:collection:subscribe`;
|
|
179
|
+
* `added`/`updated`/`removed` deltas stream to the scope room and merge
|
|
180
|
+
* id-keyed with per-item last-writer-wins; `reset` deltas trigger a
|
|
181
|
+
* debounced re-snapshot. Reconnects re-snapshot automatically (the same
|
|
182
|
+
* registry cycle entity subscriptions use), pruning rows deleted while
|
|
183
|
+
* offline when the server provides membership `ids`.
|
|
184
|
+
*
|
|
185
|
+
* Subscriptions are deduped across components by scope; pagination reuses
|
|
186
|
+
* the subscribe event with a cursor via `loadMore`.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```tsx
|
|
190
|
+
* function ChatWindow({ chatId }: { chatId: string }) {
|
|
191
|
+
* const { items: messages, loadMore, hasMore } = useCollection<MessageDTO>(
|
|
192
|
+
* "messageService",
|
|
193
|
+
* "byChat",
|
|
194
|
+
* chatId,
|
|
195
|
+
* { compare: (a, b) => a.createdAt.localeCompare(b.createdAt) },
|
|
196
|
+
* );
|
|
197
|
+
* // messages update live; loadMore() pages in older history
|
|
198
|
+
* }
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
declare function useCollection<TItem extends {
|
|
202
|
+
id: string;
|
|
203
|
+
}>(serviceName: string, collection: string, scopeId: string | null, options?: UseCollectionOptions<TItem>): UseCollectionResult<TItem>;
|
|
204
|
+
|
|
174
205
|
/**
|
|
175
206
|
* Hook for listening to custom socket events with managed lifecycle.
|
|
176
207
|
*
|
|
@@ -178,6 +209,11 @@ declare function useSubscription<TData extends {
|
|
|
178
209
|
* and connection-awareness. Each component gets its own listeners (no dedup needed).
|
|
179
210
|
* Room scoping is handled server-side via `emitToRoom`.
|
|
180
211
|
*
|
|
212
|
+
* Event names and payloads are typed via the augmentable `QuickdrawEventMap`
|
|
213
|
+
* (from the package root): augmented names get payload checking and
|
|
214
|
+
* autocomplete; unlisted names still work, untyped — nothing breaks if the
|
|
215
|
+
* map stays empty.
|
|
216
|
+
*
|
|
181
217
|
* Pair with `useSubscription` to ensure the socket is in the correct room:
|
|
182
218
|
* `useSubscription` manages room membership, `useRoomEvents` manages event listeners.
|
|
183
219
|
*
|
|
@@ -205,7 +241,7 @@ declare function useSubscription<TData extends {
|
|
|
205
241
|
* }
|
|
206
242
|
* ```
|
|
207
243
|
*/
|
|
208
|
-
declare function useRoomEvents(handlers:
|
|
244
|
+
declare function useRoomEvents(handlers: QuickdrawRoomEventHandlers, options?: UseRoomEventsOptions): void;
|
|
209
245
|
|
|
210
246
|
/**
|
|
211
247
|
* Hook for sending fire-and-forget channel messages (the high-frequency
|
|
@@ -237,6 +273,96 @@ declare function useRoomEvents(handlers: Record<string, (data: never) => void>,
|
|
|
237
273
|
*/
|
|
238
274
|
declare function useChannelSend<TPayload = unknown>(serviceName: string, channelName: string): UseChannelSendResult<TPayload>;
|
|
239
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Pure merge logic for collection subscriptions — the correctness heart of
|
|
278
|
+
* `useCollection`, kept free of React/socket dependencies so every rule is
|
|
279
|
+
* unit-testable.
|
|
280
|
+
*
|
|
281
|
+
* Invariants:
|
|
282
|
+
* - Everything is id-keyed; live deltas and paged history compose.
|
|
283
|
+
* - Per-item last-writer-wins by `rev` (epoch ms). A delta older than what
|
|
284
|
+
* the cache holds for that id is ignored; snapshot/page items only
|
|
285
|
+
* overwrite cached items whose rev predates the response's rev.
|
|
286
|
+
* - Removals leave a tombstone rev in `revById` so a stale `added` cannot
|
|
287
|
+
* resurrect a deleted row; snapshots garbage-collect stale tombstones.
|
|
288
|
+
*/
|
|
289
|
+
interface CollectionCacheEntry<TItem extends {
|
|
290
|
+
id: string;
|
|
291
|
+
}> {
|
|
292
|
+
byId: Record<string, TItem>;
|
|
293
|
+
/** Insertion/server order; render order applies `compare` on top. */
|
|
294
|
+
order: string[];
|
|
295
|
+
/** Last-known rev per id — includes tombstones for removed ids. */
|
|
296
|
+
revById: Record<string, number>;
|
|
297
|
+
nextCursor: string | null;
|
|
298
|
+
totalCount: number | null;
|
|
299
|
+
/** Rev of the last cursor-less snapshot applied. */
|
|
300
|
+
snapshotRev: number;
|
|
301
|
+
}
|
|
302
|
+
interface ApplyDeltaOptions {
|
|
303
|
+
/** Where `added` lands when the id is new. Default "end". */
|
|
304
|
+
insertPosition?: "start" | "end";
|
|
305
|
+
}
|
|
306
|
+
interface ApplyDeltaResult<TItem extends {
|
|
307
|
+
id: string;
|
|
308
|
+
}> {
|
|
309
|
+
entry: CollectionCacheEntry<TItem>;
|
|
310
|
+
/** True when the delta was a `reset` — caller schedules a re-snapshot. */
|
|
311
|
+
resetRequested: boolean;
|
|
312
|
+
}
|
|
313
|
+
declare function createEmptyEntry<TItem extends {
|
|
314
|
+
id: string;
|
|
315
|
+
}>(): CollectionCacheEntry<TItem>;
|
|
316
|
+
/**
|
|
317
|
+
* Apply a cursor-less snapshot (initial load or re-snapshot).
|
|
318
|
+
*
|
|
319
|
+
* - When `ids` is present it is the authoritative membership: cached items
|
|
320
|
+
* absent from it are pruned — unless a live delta newer than the snapshot
|
|
321
|
+
* put them there (`revById[id] > snapshot.rev`), in which case the live
|
|
322
|
+
* push wins.
|
|
323
|
+
* - Page items upsert unless the cache holds a newer rev for that id.
|
|
324
|
+
* - Order: page order first (fresh server order), then surviving previously
|
|
325
|
+
* known ids in their prior relative order (paged-in history stays).
|
|
326
|
+
* - Tombstones older than the snapshot rev are garbage-collected; newer
|
|
327
|
+
* ones keep suppressing their id (the removal happened after the
|
|
328
|
+
* snapshot query ran).
|
|
329
|
+
*/
|
|
330
|
+
declare function applySnapshot<TItem extends {
|
|
331
|
+
id: string;
|
|
332
|
+
}>(prev: CollectionCacheEntry<TItem> | null | undefined, snapshot: CollectionSnapshotResponse<TItem>): CollectionCacheEntry<TItem>;
|
|
333
|
+
/**
|
|
334
|
+
* Apply a cursored page (loadMore): upsert-only, never prune. Unseen ids
|
|
335
|
+
* append at the end in page order; existing ids keep their position (and
|
|
336
|
+
* their item, when the cache holds a newer rev).
|
|
337
|
+
*/
|
|
338
|
+
declare function applyPage<TItem extends {
|
|
339
|
+
id: string;
|
|
340
|
+
}>(prev: CollectionCacheEntry<TItem> | null | undefined, page: CollectionSnapshotResponse<TItem>): CollectionCacheEntry<TItem>;
|
|
341
|
+
/**
|
|
342
|
+
* Apply a live delta. Returns the previous entry object unchanged when the
|
|
343
|
+
* delta is stale (rev LWW) so referential equality can skip re-renders.
|
|
344
|
+
*/
|
|
345
|
+
declare function applyDelta<TItem extends {
|
|
346
|
+
id: string;
|
|
347
|
+
}>(prev: CollectionCacheEntry<TItem> | null | undefined, delta: CollectionDelta<TItem>, options?: ApplyDeltaOptions): ApplyDeltaResult<TItem>;
|
|
348
|
+
/**
|
|
349
|
+
* Apply a batch of buffered deltas in arrival order (used when deltas
|
|
350
|
+
* raced an in-flight snapshot). `resetRequested` is true if any was a reset.
|
|
351
|
+
*/
|
|
352
|
+
declare function applyDeltas<TItem extends {
|
|
353
|
+
id: string;
|
|
354
|
+
}>(prev: CollectionCacheEntry<TItem> | null | undefined, deltas: CollectionDelta<TItem>[], options?: ApplyDeltaOptions): ApplyDeltaResult<TItem>;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Error thrown by client hooks when a service call acks with
|
|
358
|
+
* `{ success: false }`. Carries the server's optional numeric code
|
|
359
|
+
* (e.g. 401, 403, 429) so callers can branch without string matching.
|
|
360
|
+
*/
|
|
361
|
+
declare class ServiceCallError extends Error {
|
|
362
|
+
readonly code?: number;
|
|
363
|
+
constructor(message: string, code?: number);
|
|
364
|
+
}
|
|
365
|
+
|
|
240
366
|
type CommitMode = "change" | "blur" | "debounce";
|
|
241
367
|
interface UseSocketInputOptions<TEntry, TAllowedUpdate, TKey extends keyof TAllowedUpdate> {
|
|
242
368
|
/**
|
|
@@ -547,4 +673,4 @@ declare function buildBreadcrumbs(pathname: string, navigation: NavItem[]): Brea
|
|
|
547
673
|
*/
|
|
548
674
|
declare function routeRequiresAuth(pathname: string, navigation: NavItem[], userMenuItems?: NavItem[]): boolean;
|
|
549
675
|
|
|
550
|
-
export { type BreadcrumbItem, type CommitMode, type JWTPayload, type NavItem, QuickdrawProvider, QuickdrawProviderProps, QuickdrawSocketContextValue, SocketCheckbox, type SocketCheckboxProps, SocketSelect, type SocketSelectProps, SocketSlider, type SocketSliderProps, SocketSwitch, type SocketSwitchProps, SocketTextField, type SocketTextFieldProps, UseChannelSendResult, UseRoomEventsOptions, UseServiceOptions, UseServiceQueryOptions, UseServiceQueryResult, UseServiceResult, type UseSocketInputOptions, type UseSocketInputResult, UseSubscriptionOptions, UseSubscriptionResult, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useChannelSend, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
|
|
676
|
+
export { type BreadcrumbItem, type CollectionCacheEntry, type CommitMode, type JWTPayload, type NavItem, QuickdrawProvider, QuickdrawProviderProps, QuickdrawRoomEventHandlers, QuickdrawSocketContextValue, ServiceCallError, SocketCheckbox, type SocketCheckboxProps, SocketSelect, type SocketSelectProps, SocketSlider, type SocketSliderProps, SocketSwitch, type SocketSwitchProps, SocketTextField, type SocketTextFieldProps, UseChannelSendResult, UseCollectionOptions, UseCollectionResult, UseRoomEventsOptions, UseServiceOptions, UseServiceQueryOptions, UseServiceQueryResult, UseServiceResult, type UseSocketInputOptions, type UseSocketInputResult, UseSubscriptionOptions, UseSubscriptionResult, applyDelta, applyDeltas, applyPage, applySnapshot, buildBreadcrumbs, clearAuthToken, createEmptyEntry, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useChannelSend, useCollection, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
|