@classytic/arc-next 0.4.0 → 0.5.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.
@@ -0,0 +1,47 @@
1
+ //#region src/presets/tree.ts
2
+ /**
3
+ * Adds tree preset methods to a BaseApi.
4
+ *
5
+ * Mirrors arc's `tree` preset — for resources with a `parentId` field, exposes
6
+ * `getTree` (full hierarchy) and `getChildren` (one level deep). Backend caps
7
+ * tree depth via the preset config.
8
+ *
9
+ * @example
10
+ * import { withTree } from '@classytic/arc-next/presets/tree';
11
+ * const categories = withTree(createCrudApi<Category>('categories'));
12
+ *
13
+ * const root = await categories.getTree();
14
+ * const kids = await categories.getChildren({ parentId: 'engineering' });
15
+ */
16
+ function withTree(api) {
17
+ return Object.assign(api, {
18
+ async getTree({ token = null, organizationId = null, params = {}, options = {} } = {}) {
19
+ const merged = {
20
+ ...api.config.defaultParams,
21
+ ...params
22
+ };
23
+ return api.request("GET", `${api.baseUrl}/tree`, {
24
+ token,
25
+ organizationId,
26
+ params: merged,
27
+ options
28
+ });
29
+ },
30
+ async getChildren({ token = null, organizationId = null, parentId, params = {}, options = {} }) {
31
+ if (!parentId) throw new Error("Parent ID is required");
32
+ const merged = {
33
+ ...api.config.defaultParams,
34
+ ...params
35
+ };
36
+ return api.request("GET", `${api.baseUrl}/${parentId}/children`, {
37
+ token,
38
+ organizationId,
39
+ params: merged,
40
+ options
41
+ });
42
+ }
43
+ });
44
+ }
45
+
46
+ //#endregion
47
+ export { withTree };
package/dist/query.d.ts CHANGED
@@ -1,18 +1,7 @@
1
- import { InfiniteData, QueryClient, QueryKey } from "@tanstack/react-query";
1
+ import { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
2
+ import { InfiniteData, QueryKey } from "@tanstack/react-query";
2
3
 
3
4
  //#region src/query.d.ts
4
- interface PaginationData {
5
- /** Pagination method detected from response (offset | keyset | aggregate) */
6
- method: 'offset' | 'keyset' | 'aggregate' | null;
7
- total: number;
8
- pages: number;
9
- page: number;
10
- limit: number;
11
- hasNext: boolean;
12
- hasPrev: boolean;
13
- /** Keyset cursor for next page (keyset pagination only) */
14
- next?: string | null;
15
- }
16
5
  /** Request-level options passed through to the fetch call */
17
6
  interface RequestPassthrough {
18
7
  cache?: RequestCache;
@@ -80,50 +69,6 @@ interface DetailQueryResult<T> {
80
69
  refetch: () => Promise<unknown>;
81
70
  data: unknown;
82
71
  }
83
- interface QueryKeys {
84
- all: string[];
85
- lists: () => QueryKey;
86
- list: (params?: unknown) => QueryKey;
87
- details: () => QueryKey;
88
- detail: (id: string) => QueryKey;
89
- custom: (key: string, ...args: unknown[]) => QueryKey;
90
- scopedList: (scope: string, params?: unknown) => QueryKey;
91
- }
92
- interface CacheUtils<T> {
93
- invalidateAll: (client: QueryClient) => Promise<void>;
94
- invalidateLists: (client: QueryClient) => Promise<void>;
95
- invalidateDetail: (client: QueryClient, id: string) => Promise<void>;
96
- setDetail: (client: QueryClient, id: string, data: T) => void;
97
- getDetail: (client: QueryClient, id: string) => T | undefined;
98
- removeDetail: (client: QueryClient, id: string) => void;
99
- }
100
- declare const DEFAULT_QUERY_CONFIG: {
101
- readonly staleTime: number;
102
- readonly gcTime: number;
103
- readonly refetchOnWindowFocus: false;
104
- readonly retry: 0;
105
- };
106
- /** Pre-built query config presets for common data freshness patterns. */
107
- declare const QUERY_CONFIGS: {
108
- /** Live data: 20s stale, 30s polling */readonly realtime: {
109
- readonly staleTime: 20000;
110
- readonly refetchInterval: 30000;
111
- }; /** Frequently updated: 60s stale */
112
- readonly frequent: {
113
- readonly staleTime: 60000;
114
- }; /** Stable data: 5min stale (same as default) */
115
- readonly stable: {
116
- readonly staleTime: 300000;
117
- }; /** Rarely changes: 10min stale */
118
- readonly static: {
119
- readonly staleTime: 600000;
120
- };
121
- };
122
- declare function getItemId(item: unknown): string | null;
123
- declare function extractItem<T>(data: unknown): T | null;
124
- declare function updateListCache<T>(listData: unknown, updater: (items: T[]) => T[]): unknown;
125
- declare function createQueryKeys(entityKey: string): QueryKeys;
126
- declare function createCacheUtils<T>(KEYS: QueryKeys): CacheUtils<T>;
127
72
  interface CreateListQueryConfig {
128
73
  queryKey: QueryKey;
129
74
  queryFn: (context: {
@@ -221,11 +166,88 @@ declare function useInfiniteListQuery<T>({
221
166
  getPreviousPageParam,
222
167
  maxPages
223
168
  }: CreateInfiniteListQueryConfig): InfiniteListQueryResult<T>;
224
- /** @deprecated Use `useListQuery` */
225
- declare const createListQuery: typeof useListQuery;
226
- /** @deprecated Use `useDetailQuery` */
227
- declare const createDetailQuery: typeof useDetailQuery;
228
- /** @deprecated Use `useInfiniteListQuery` */
229
- declare const createInfiniteListQuery: typeof useInfiniteListQuery;
169
+ /** Recognized data-freshness presets. Maps to QUERY_CONFIGS. */
170
+ type QueryFreshness = keyof typeof QUERY_CONFIGS;
171
+ /**
172
+ * Extracts the inner type of an Arc envelope `{ success, data: T }`.
173
+ * Falls through to `T` itself when the response isn't an envelope.
174
+ */
175
+ type ExtractData<T> = T extends {
176
+ success: unknown;
177
+ data: infer D;
178
+ } ? D : T;
179
+ /** Per-call request pass-through and TanStack Query overrides. */
180
+ interface UseApiQueryOptions {
181
+ staleTime?: number;
182
+ gcTime?: number;
183
+ refetchOnWindowFocus?: boolean;
184
+ refetchInterval?: number | false;
185
+ refetchIntervalInBackground?: boolean;
186
+ retry?: boolean | number;
187
+ /** Limit re-renders to changes in these specific fields (perf optimization). */
188
+ notifyOnChangeProps?: ('data' | 'error' | 'isLoading' | 'isFetching' | 'isError' | 'isSuccess' | 'isStale')[];
189
+ }
190
+ interface UseApiQueryConfig<TResponse, TData> {
191
+ queryKey: QueryKey;
192
+ queryFn: (context: {
193
+ signal: AbortSignal;
194
+ }) => Promise<TResponse>;
195
+ enabled?: boolean;
196
+ /** Freshness preset name (`realtime` | `frequent` | `stable` | `static`). */
197
+ freshness?: QueryFreshness;
198
+ /**
199
+ * Custom projection from the raw response. When omitted, the hook auto-unwraps
200
+ * Arc envelopes (`{ success, data }`) and returns `data`. Non-envelope responses
201
+ * pass through unchanged.
202
+ */
203
+ select?: (response: TResponse) => TData;
204
+ /** TanStack Query overrides (staleTime, refetchInterval, retry, etc.). */
205
+ options?: UseApiQueryOptions;
206
+ }
207
+ interface UseApiQueryResult<TData> {
208
+ data: TData | null;
209
+ isLoading: boolean;
210
+ isFetching: boolean;
211
+ isError: boolean;
212
+ isSuccess: boolean;
213
+ isStale: boolean;
214
+ error: Error | null;
215
+ refetch: () => Promise<unknown>;
216
+ }
217
+ /**
218
+ * Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
219
+ * endpoints). Wraps TanStack Query with three ergonomics on top:
220
+ *
221
+ * 1. **Envelope auto-unwrap.** `{ success, data: T }` responses are projected to
222
+ * `T` automatically. Override with a custom `select` for shape changes or
223
+ * multi-field projections.
224
+ * 2. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
225
+ * 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
226
+ * 3. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
227
+ * isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
228
+ *
229
+ * @example
230
+ * // Auto-unwrap envelope
231
+ * const { data } = useApiQuery<ApiResponse<DashboardStats>>({
232
+ * queryKey: ['dashboard', 'stats'],
233
+ * queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
234
+ * freshness: 'realtime',
235
+ * });
236
+ *
237
+ * // Custom projection
238
+ * const { data } = useApiQuery({
239
+ * queryKey: ['ledger', accountId],
240
+ * queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
241
+ * select: (res) => res.data?.entries ?? [],
242
+ * });
243
+ */
244
+ declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>>({
245
+ queryKey,
246
+ queryFn,
247
+ enabled,
248
+ freshness,
249
+ select,
250
+ options
251
+ }: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
230
252
  //#endregion
231
- export { CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, PaginationData, QUERY_CONFIGS, QueryKeys, RequestPassthrough, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
253
+ export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/query.js CHANGED
@@ -1,152 +1,10 @@
1
1
  "use client";
2
2
 
3
+ import { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
3
4
  import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
4
- import { useEffect, useMemo } from "react";
5
+ import { useCallback, useEffect, useMemo, useRef } from "react";
5
6
 
6
7
  //#region src/query.ts
7
- const DEFAULT_QUERY_CONFIG = {
8
- staleTime: 300 * 1e3,
9
- gcTime: 1800 * 1e3,
10
- refetchOnWindowFocus: false,
11
- retry: 0
12
- };
13
- /** Pre-built query config presets for common data freshness patterns. */
14
- const QUERY_CONFIGS = {
15
- realtime: {
16
- staleTime: 2e4,
17
- refetchInterval: 3e4
18
- },
19
- frequent: { staleTime: 6e4 },
20
- stable: { staleTime: 3e5 },
21
- static: { staleTime: 6e5 }
22
- };
23
- function getItemId(item) {
24
- if (!item || typeof item !== "object") return null;
25
- const obj = item;
26
- const id = obj._id ?? obj.id;
27
- return typeof id === "string" ? id : id ? String(id) : null;
28
- }
29
- function normalizePagination(data) {
30
- if (!data || typeof data !== "object") return null;
31
- const d = data;
32
- const method = d.method ?? null;
33
- const isKeyset = method === "keyset" || d.hasMore != null && d.total == null && d.pages == null;
34
- const hasTotal = d.total != null || d.totalDocs != null;
35
- const hasPages = d.pages != null || d.totalPages != null;
36
- if (!hasTotal && !hasPages && !isKeyset) return null;
37
- return {
38
- method,
39
- total: Number(d.total ?? d.totalDocs ?? 0),
40
- pages: Number(d.pages ?? d.totalPages ?? (isKeyset ? 0 : 1)),
41
- page: Number(d.page ?? d.currentPage ?? (isKeyset ? 0 : 1)),
42
- limit: Number(d.limit ?? 10),
43
- hasNext: Boolean(d.hasNext ?? d.hasNextPage ?? d.hasMore ?? false),
44
- hasPrev: Boolean(d.hasPrev ?? d.hasPrevPage ?? false),
45
- ...isKeyset ? { next: d.next ?? null } : {}
46
- };
47
- }
48
- /** Well-known keys checked in order for list responses. */
49
- const LIST_KEYS = [
50
- "docs",
51
- "data",
52
- "items",
53
- "results"
54
- ];
55
- /** Well-known keys checked in order for detail responses. */
56
- const DETAIL_KEYS = [
57
- "data",
58
- "doc",
59
- "item",
60
- "result"
61
- ];
62
- function extractItems(data) {
63
- if (!data) return [];
64
- if (Array.isArray(data)) return data;
65
- if (typeof data !== "object") return [];
66
- const d = data;
67
- for (const key of LIST_KEYS) if (Array.isArray(d[key])) return d[key];
68
- for (const value of Object.values(d)) if (Array.isArray(value)) return value;
69
- return [];
70
- }
71
- function extractItem(data) {
72
- if (data == null) return null;
73
- if (typeof data !== "object") return data;
74
- const d = data;
75
- for (const key of DETAIL_KEYS) if (d[key] != null) return d[key];
76
- return d;
77
- }
78
- function updateListCache(listData, updater) {
79
- if (!listData) return listData;
80
- if (Array.isArray(listData)) return updater(listData);
81
- if (typeof listData !== "object") return listData;
82
- const d = listData;
83
- let arrayField = null;
84
- for (const key of LIST_KEYS) if (Array.isArray(d[key])) {
85
- arrayField = key;
86
- break;
87
- }
88
- if (!arrayField) {
89
- for (const [key, value] of Object.entries(d)) if (Array.isArray(value)) {
90
- arrayField = key;
91
- break;
92
- }
93
- }
94
- if (!arrayField) return listData;
95
- const updated = updater(d[arrayField]);
96
- const original = d[arrayField];
97
- const delta = updated.length - original.length;
98
- const result = {
99
- ...d,
100
- [arrayField]: updated
101
- };
102
- if (delta !== 0) {
103
- if (d.total != null) result.total = Math.max(0, Number(d.total) + delta);
104
- if (d.totalDocs != null) result.totalDocs = Math.max(0, Number(d.totalDocs) + delta);
105
- }
106
- return result;
107
- }
108
- function createQueryKeys(entityKey) {
109
- return {
110
- all: [entityKey],
111
- lists: () => [entityKey, "list"],
112
- list: (params) => [
113
- entityKey,
114
- "list",
115
- params
116
- ],
117
- details: () => [entityKey, "detail"],
118
- detail: (id) => [
119
- entityKey,
120
- "detail",
121
- id
122
- ],
123
- custom: (key, ...args) => [
124
- entityKey,
125
- key,
126
- ...args
127
- ],
128
- scopedList: (scope, params) => [
129
- entityKey,
130
- "list",
131
- {
132
- _scope: scope,
133
- ...params
134
- }
135
- ]
136
- };
137
- }
138
- function createCacheUtils(KEYS) {
139
- return {
140
- invalidateAll: (client) => client.invalidateQueries({ queryKey: KEYS.all }),
141
- invalidateLists: (client) => client.invalidateQueries({ queryKey: KEYS.lists() }),
142
- invalidateDetail: (client, id) => client.invalidateQueries({ queryKey: KEYS.detail(id) }),
143
- setDetail: (client, id, data) => client.setQueryData(KEYS.detail(id), { data }),
144
- getDetail: (client, id) => {
145
- return client.getQueryData(KEYS.detail(id))?.data;
146
- },
147
- removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) })
148
- };
149
- }
150
8
  function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, itemIdResolver, select }) {
151
9
  const queryClient = useQueryClient();
152
10
  const query = useQuery({
@@ -239,12 +97,72 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
239
97
  data: query.data
240
98
  };
241
99
  }
242
- /** @deprecated Use `useListQuery` */
243
- const createListQuery = useListQuery;
244
- /** @deprecated Use `useDetailQuery` */
245
- const createDetailQuery = useDetailQuery;
246
- /** @deprecated Use `useInfiniteListQuery` */
247
- const createInfiniteListQuery = useInfiniteListQuery;
100
+ /**
101
+ * Strict Arc envelope detector — requires BOTH `success` and `data` keys.
102
+ *
103
+ * Stricter than checking only `data` so we don't mis-unwrap responses that happen
104
+ * to use a `data` field for unrelated semantics (e.g. a chart that returns
105
+ * `{ data: number[], labels: [] }`).
106
+ */
107
+ function isArcEnvelope(value) {
108
+ return typeof value === "object" && value !== null && "success" in value && "data" in value;
109
+ }
110
+ /**
111
+ * Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
112
+ * endpoints). Wraps TanStack Query with three ergonomics on top:
113
+ *
114
+ * 1. **Envelope auto-unwrap.** `{ success, data: T }` responses are projected to
115
+ * `T` automatically. Override with a custom `select` for shape changes or
116
+ * multi-field projections.
117
+ * 2. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
118
+ * 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
119
+ * 3. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
120
+ * isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
121
+ *
122
+ * @example
123
+ * // Auto-unwrap envelope
124
+ * const { data } = useApiQuery<ApiResponse<DashboardStats>>({
125
+ * queryKey: ['dashboard', 'stats'],
126
+ * queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
127
+ * freshness: 'realtime',
128
+ * });
129
+ *
130
+ * // Custom projection
131
+ * const { data } = useApiQuery({
132
+ * queryKey: ['ledger', accountId],
133
+ * queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
134
+ * select: (res) => res.data?.entries ?? [],
135
+ * });
136
+ */
137
+ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, options = {} }) {
138
+ const selectRef = useRef(select);
139
+ selectRef.current = select;
140
+ const projection = useCallback((response) => {
141
+ const fn = selectRef.current;
142
+ if (fn) return fn(response);
143
+ return isArcEnvelope(response) ? response.data : response;
144
+ }, []);
145
+ const preset = freshness ? QUERY_CONFIGS[freshness] : void 0;
146
+ const query = useQuery({
147
+ queryKey,
148
+ queryFn: ({ signal }) => queryFn({ signal }),
149
+ enabled,
150
+ ...DEFAULT_QUERY_CONFIG,
151
+ ...preset,
152
+ ...options,
153
+ select: projection
154
+ });
155
+ return {
156
+ data: query.data ?? null,
157
+ isLoading: query.isLoading,
158
+ isFetching: query.isFetching,
159
+ isError: query.isError,
160
+ isSuccess: query.isSuccess,
161
+ isStale: query.isStale,
162
+ error: query.error,
163
+ refetch: query.refetch
164
+ };
165
+ }
248
166
 
249
167
  //#endregion
250
- export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
168
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/sse.d.ts CHANGED
@@ -1,62 +1,148 @@
1
1
  import { QueryKey } from "@tanstack/react-query";
2
2
 
3
3
  //#region src/sse.d.ts
4
- interface ArcServerEvent {
4
+ /**
5
+ * Build an authenticated SSE URL using the global client + auth singletons.
6
+ *
7
+ * Thin alias for {@link import('./client.js').buildStreamUrl} with the HTTP
8
+ * protocol — kept as a named export so SSE consumers don't have to think about
9
+ * the `protocol` arg.
10
+ *
11
+ * @example
12
+ * const es = new EventSource(buildSseUrl('/jobs/stream', { jobId }), {
13
+ * withCredentials: getAuthMode() === 'cookie',
14
+ * });
15
+ */
16
+ declare function buildSseUrl(path: string, params?: Record<string, string | number | boolean | null | undefined>): string;
17
+ /**
18
+ * Generic Arc server event envelope.
19
+ *
20
+ * Defaults to `unknown` payload — narrow with the generic when you control
21
+ * the broadcast shape (`ArcServerEvent<Todo>`). For the canonical CRUD shape,
22
+ * use {@link CrudEvent} which constrains `type` to `<resource>.<operation>`
23
+ * and `operation` to the three lifecycle verbs.
24
+ */
25
+ interface ArcServerEvent<TData = unknown> {
5
26
  type: string;
6
27
  resource: string;
7
- data: unknown;
28
+ data: TData;
8
29
  timestamp: string;
9
30
  id?: string;
10
31
  }
11
- interface EventStreamOptions {
12
- /** SSE endpoint URL (absolute or relative to baseUrl). Default: `/{basePath}/{resource}/events/stream` */
32
+ /** Lifecycle operations Arc emits on every CRUD broadcast. */
33
+ type CrudOperation = "created" | "updated" | "deleted";
34
+ /**
35
+ * Typed CRUD event narrowed to Arc's `<resource>.<operation>` envelope.
36
+ *
37
+ * Arc auto-emits this shape from `BaseController` for every list/get/create/
38
+ * update/delete. Pass `<TDoc>` so SSE/WS callbacks get inference for free:
39
+ *
40
+ * ```ts
41
+ * subscribeToEvents<CrudEvent<Todo>>({
42
+ * resource: 'todo',
43
+ * onEvent: (e) => console.log(e.operation, e.data.title),
44
+ * });
45
+ * ```
46
+ */
47
+ interface CrudEvent<TDoc = unknown> extends ArcServerEvent<TDoc> {
48
+ /** Always `<resource>.<operation>` (e.g. `'todo.created'`). */
49
+ type: string;
50
+ operation: CrudOperation;
51
+ }
52
+ interface SubscribeToEventsOptions<TData = unknown> {
53
+ /** Full SSE endpoint URL. When set, overrides `path` and `baseUrl`. */
13
54
  url?: string;
14
- /** Resource name used for the default endpoint path and event filtering. */
55
+ /** Resource name for auto pattern filtering + named-event derivation. */
15
56
  resource?: string;
16
- /** Base path for the events endpoint. Default: '/api/v1' */
17
- basePath?: string;
18
- /** Event patterns to listen for (e.g., ['agents.created', 'agents.updated']). When empty, all events are received. */
57
+ /** Endpoint path (default: `/events/stream`). */
58
+ path?: string;
59
+ /** Event patterns to listen for (e.g. `['todo.*']`). Empty all events. */
19
60
  patterns?: string[];
20
- /** Query keys to invalidate when any event is received. */
21
- invalidateQueries?: QueryKey[];
22
- /** Callback for each event. */
23
- onEvent?: (event: ArcServerEvent) => void;
24
- /** Callback for connection state changes. */
61
+ /**
62
+ * Named SSE event types to subscribe to via `addEventListener`.
63
+ * Falls back to `patterns` (literal entries) or `[<resource>.created|updated|deleted]`.
64
+ * Pass `[]` to opt out of named-event subscription entirely.
65
+ */
66
+ eventTypes?: string[];
67
+ /** Per-event callback. */
68
+ onEvent?: (event: ArcServerEvent<TData>) => void;
69
+ /** Connection-state callback. */
25
70
  onConnectionChange?: (connected: boolean) => void;
26
- /** Whether the stream is enabled. Default: true */
27
- enabled?: boolean;
28
- /** Reconnect delay in ms. Default: 3000 */
71
+ /** Reconnect delay in ms. Default: 3000. */
29
72
  reconnectDelay?: number;
30
- /** Maximum reconnect attempts before giving up. Default: Infinity */
73
+ /** Maximum reconnect attempts. Default: Infinity. */
31
74
  maxReconnectAttempts?: number;
32
75
  /** Whether to include credentials (cookies). Derived from authMode when not set. */
33
76
  withCredentials?: boolean;
34
77
  }
35
- interface EventStreamResult {
36
- /** Whether the EventSource is currently connected. */
78
+ /** Handle returned by {@link subscribeToEvents}. Stable across reconnects. */
79
+ interface SubscribeToEventsHandle {
80
+ close: () => void;
81
+ reconnect: () => void;
82
+ isConnected: () => boolean;
83
+ }
84
+ interface EventStreamOptions<TData = unknown> extends SubscribeToEventsOptions<TData> {
85
+ /** Query keys to invalidate when any matching event arrives. */
86
+ invalidateQueries?: QueryKey[];
87
+ /** Whether the stream is active. Default: true. */
88
+ enabled?: boolean;
89
+ /**
90
+ * Whether to track `lastEvent` in React state. Default: true.
91
+ *
92
+ * Set to `false` for high-volume streams (telemetry, live tickers) where
93
+ * the consumer uses `onEvent` for fire-and-forget handling and never reads
94
+ * `result.lastEvent`. Each inbound frame skips a `setState` call,
95
+ * eliminating per-event re-renders.
96
+ */
97
+ trackLastEvent?: boolean;
98
+ /**
99
+ * Whether to track `eventCount` in React state. Default: true.
100
+ *
101
+ * Same trade-off as `trackLastEvent`: skip the counter `setState` for
102
+ * high-volume streams that don't read it.
103
+ */
104
+ trackEventCount?: boolean;
105
+ }
106
+ interface EventStreamResult<TData = unknown> {
37
107
  isConnected: boolean;
38
- /** The most recently received event. */
39
- lastEvent: ArcServerEvent | null;
40
- /** Number of events received since connection. */
108
+ lastEvent: ArcServerEvent<TData> | null;
41
109
  eventCount: number;
42
- /** Close the connection manually. */
43
110
  close: () => void;
44
- /** Reconnect after a manual close. */
45
111
  reconnect: () => void;
46
112
  }
113
+ /**
114
+ * Subscribe to an Arc SSE stream from any JS context (React, Node, Bun, tests).
115
+ * Pure function — no React hook required. Returns a handle with `close()` /
116
+ * `reconnect()` / `isConnected()`.
117
+ *
118
+ * Reconnect uses exponential backoff (×1.5 per attempt, capped at 30s).
119
+ * Subscriptions persist across reconnect.
120
+ *
121
+ * @example
122
+ * const sub = subscribeToEvents<CrudEvent<Todo>>({
123
+ * resource: 'todo',
124
+ * onEvent: (e) => console.log(e.operation, e.data.title),
125
+ * });
126
+ * // ...later
127
+ * sub.close();
128
+ */
129
+ declare function subscribeToEvents<TData = unknown>(options: SubscribeToEventsOptions<TData>): SubscribeToEventsHandle;
47
130
  /**
48
131
  * Subscribe to Arc server-sent events for real-time cache invalidation.
49
132
  *
50
- * Uses the browser's native `EventSource` API for automatic reconnection
133
+ * Uses the browser's native `EventSource` for automatic reconnection
51
134
  * and efficient server-push. Events trigger query invalidation so TanStack Query
52
135
  * refetches affected data automatically.
53
136
  *
137
+ * Internally delegates to {@link subscribeToEvents} — for non-React contexts
138
+ * (Node, tests, plain JS) call that directly.
139
+ *
54
140
  * @example
55
- * const { isConnected } = useEventStream({
56
- * resource: 'agents',
57
- * invalidateQueries: [agentKeys.lists()],
141
+ * const { isConnected } = useEventStream<CrudEvent<Todo>>({
142
+ * resource: 'todo',
143
+ * invalidateQueries: [todoKeys.lists()],
58
144
  * });
59
145
  */
60
- declare function useEventStream(options: EventStreamOptions): EventStreamResult;
146
+ declare function useEventStream<TData = unknown>(options: EventStreamOptions<TData>): EventStreamResult<TData>;
61
147
  //#endregion
62
- export { ArcServerEvent, EventStreamOptions, EventStreamResult, useEventStream };
148
+ export { ArcServerEvent, CrudEvent, CrudOperation, EventStreamOptions, EventStreamResult, SubscribeToEventsHandle, SubscribeToEventsOptions, buildSseUrl, subscribeToEvents, useEventStream };