@talelk/newui-data 0.0.0-stage → 1.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.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. package/src/url-state.ts +168 -0
@@ -0,0 +1,173 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ getConfig,
4
+ isAbortError,
5
+ normalizeError,
6
+ RequestError,
7
+ request,
8
+ resetConfig,
9
+ setConfig,
10
+ } from './client'
11
+
12
+ function mockResponse(
13
+ body: unknown,
14
+ init: { ok?: boolean; status?: number; statusText?: string } = {},
15
+ ): Response {
16
+ const { ok = true, status = 200, statusText = 'OK' } = init
17
+ return {
18
+ ok,
19
+ status,
20
+ statusText,
21
+ json: async () => body,
22
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
23
+ } as unknown as Response
24
+ }
25
+
26
+ interface FetchCall {
27
+ mock: { calls: Array<[string, RequestInit]> }
28
+ }
29
+
30
+ function stubFetch(response: Response): FetchCall {
31
+ const mock = vi.fn(async () => response)
32
+ vi.stubGlobal('fetch', mock)
33
+ return mock as unknown as FetchCall
34
+ }
35
+
36
+ afterEach(() => {
37
+ vi.unstubAllGlobals()
38
+ resetConfig()
39
+ })
40
+
41
+ describe('request', () => {
42
+ it('POSTs a dotted method to /api/method and unwraps message', async () => {
43
+ const fetchMock = stubFetch(mockResponse({ message: [1, 2, 3] }))
44
+ const result = await request({
45
+ url: 'frappe.client.get_list',
46
+ params: { doctype: 'Task' },
47
+ })
48
+
49
+ expect(result).toEqual([1, 2, 3])
50
+ expect(fetchMock).toHaveBeenCalledWith(
51
+ '/api/method/frappe.client.get_list',
52
+ expect.objectContaining({
53
+ method: 'POST',
54
+ body: JSON.stringify({ doctype: 'Task' }),
55
+ }),
56
+ )
57
+ })
58
+
59
+ it('appends GET params as the query string', async () => {
60
+ const fetchMock = stubFetch(mockResponse({ message: { name: 'T-1' } }))
61
+ await request({
62
+ url: 'frappe.client.get',
63
+ method: 'GET',
64
+ params: { doctype: 'Task', name: 'T-1' },
65
+ })
66
+
67
+ expect(fetchMock).toHaveBeenCalledWith(
68
+ '/api/method/frappe.client.get?doctype=Task&name=T-1',
69
+ expect.objectContaining({ method: 'GET' }),
70
+ )
71
+ })
72
+
73
+ it('prepends the configured base URL, headers and credentials', async () => {
74
+ setConfig({
75
+ baseUrl: 'https://example.com/',
76
+ headers: { 'X-Frappe-Site-Name': 'site' },
77
+ })
78
+ const fetchMock = stubFetch(mockResponse({ message: 'pong' }))
79
+ await request({ url: '/api/method/ping' })
80
+
81
+ expect(fetchMock).toHaveBeenCalledWith(
82
+ 'https://example.com/api/method/ping',
83
+ expect.objectContaining({
84
+ credentials: 'include',
85
+ headers: expect.objectContaining({
86
+ Accept: 'application/json',
87
+ 'X-Frappe-Site-Name': 'site',
88
+ }),
89
+ }),
90
+ )
91
+ })
92
+
93
+ it('accepts a headers getter', async () => {
94
+ setConfig({ headers: () => ({ Authorization: 'token abc' }) })
95
+ const fetchMock = stubFetch(mockResponse({ message: 'ok' }))
96
+ await request({ url: 'myapp.api.ping' })
97
+
98
+ expect(fetchMock).toHaveBeenCalledWith(
99
+ '/api/method/myapp.api.ping',
100
+ expect.objectContaining({
101
+ headers: expect.objectContaining({ Authorization: 'token abc' }),
102
+ }),
103
+ )
104
+ })
105
+
106
+ it('returns the whole body when it carries docs', async () => {
107
+ stubFetch(mockResponse({ docs: [{ name: 'T-1' }], message: 'ignored' }))
108
+ const result = await request({ url: 'run_doc_method' })
109
+ expect(result).toEqual({ docs: [{ name: 'T-1' }], message: 'ignored' })
110
+ })
111
+
112
+ it('returns the whole body for the login endpoint', async () => {
113
+ stubFetch(mockResponse({ message: 'Logged In', full_name: 'Ada' }))
114
+ const result = await request({ url: '/api/method/login', method: 'POST' })
115
+ expect(result).toEqual({ message: 'Logged In', full_name: 'Ada' })
116
+ })
117
+
118
+ it('rejects with a normalized RequestError on a non-2xx response', async () => {
119
+ stubFetch(
120
+ mockResponse(
121
+ {
122
+ exc_type: 'ValidationError',
123
+ _server_messages: JSON.stringify([
124
+ JSON.stringify({ message: 'Bad value' }),
125
+ ]),
126
+ },
127
+ { ok: false, status: 417, statusText: 'Expectation Failed' },
128
+ ),
129
+ )
130
+
131
+ const error = (await request({ url: 'x.y' }).catch(
132
+ (e) => e,
133
+ )) as RequestError
134
+ expect(error).toBeInstanceOf(RequestError)
135
+ expect(error.status).toBe(417)
136
+ expect(error.excType).toBe('ValidationError')
137
+ expect(error.messages).toEqual(['Bad value'])
138
+ })
139
+
140
+ it('falls back to Internal Server Error when the body has no message', async () => {
141
+ stubFetch(mockResponse({}, { ok: false, status: 500 }))
142
+ const error = (await request({ url: 'x.y' }).catch(
143
+ (e) => e,
144
+ )) as RequestError
145
+ expect(error.messages).toEqual(['Internal Server Error'])
146
+ })
147
+
148
+ it('throws when url is missing', async () => {
149
+ await expect(request({ url: '' })).rejects.toBeInstanceOf(RequestError)
150
+ })
151
+ })
152
+
153
+ describe('config', () => {
154
+ it('merges keys and resets', () => {
155
+ setConfig({ baseUrl: '/base' })
156
+ setConfig({ headers: { A: '1' } })
157
+ expect(getConfig()).toEqual({ baseUrl: '/base', headers: { A: '1' } })
158
+ resetConfig()
159
+ expect(getConfig()).toEqual({})
160
+ })
161
+ })
162
+
163
+ describe('normalizeError', () => {
164
+ it('coerces arbitrary values and preserves the name', () => {
165
+ expect(normalizeError('boom')).toBeInstanceOf(RequestError)
166
+
167
+ const abort = new Error('aborted')
168
+ abort.name = 'AbortError'
169
+ const normalized = normalizeError(abort)
170
+ expect(normalized).toBeInstanceOf(RequestError)
171
+ expect(isAbortError(normalized)).toBe(true)
172
+ })
173
+ })
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The framework-neutral request client for the resource layer.
3
+ *
4
+ * A Solid port of frappe-ui's `frappeRequest` + `request` pair, reduced to what
5
+ * the signal-based resources need: a configurable base URL / headers, POST to
6
+ * `/api/method/<dotted.method>`, JSON in and out, and one error shape for every
7
+ * way a request can fail. It is pure — the only side effect is `fetch` — so the
8
+ * whole thing is testable by stubbing the global.
9
+ *
10
+ * Nothing here touches Solid. `resource.ts` wraps it with signals.
11
+ */
12
+
13
+ /** Configuration merged by {@link setConfig}. Every field is optional. */
14
+ export interface ClientConfig {
15
+ /** Base URL prepended to relative (`/`-rooted) request URLs. */
16
+ baseUrl?: string
17
+ /** Extra headers merged into every request. Static, or a getter. */
18
+ headers?: Record<string, string> | (() => Record<string, string>)
19
+ /**
20
+ * Credentials mode. Defaults to `same-origin`, or `include` when a
21
+ * `baseUrl` is configured (cookie-based cross-origin auth).
22
+ */
23
+ credentials?: RequestCredentials
24
+ /** Called with Frappe's `_server_messages` when a successful response carries them. */
25
+ onServerMessages?: (messages: string[]) => void
26
+ /** The fetch implementation. Defaults to the global `fetch`. */
27
+ fetch?: typeof fetch
28
+ }
29
+
30
+ const defaults: ClientConfig = {}
31
+
32
+ /**
33
+ * Merge configuration. Keys are merged, not replaced, so calling it twice
34
+ * (`baseUrl`, then `headers`) keeps both. Use {@link resetConfig} in tests.
35
+ */
36
+ export function setConfig(config: ClientConfig): void {
37
+ Object.assign(defaults, config)
38
+ }
39
+
40
+ /** A copy of the current configuration. */
41
+ export function getConfig(): ClientConfig {
42
+ return { ...defaults }
43
+ }
44
+
45
+ /** Clear every configured value, back to the built-in defaults. */
46
+ export function resetConfig(): void {
47
+ for (const key of Object.keys(defaults) as (keyof ClientConfig)[]) {
48
+ delete defaults[key]
49
+ }
50
+ }
51
+
52
+ /** Options for a single {@link request}. */
53
+ export interface RequestOptions {
54
+ /** Absolute URL, `/api/method/…`, or a dotted method name (`frappe.client.get`). */
55
+ url: string
56
+ /** HTTP method. Defaults to `POST`, Frappe's convention for method calls. */
57
+ method?: string
58
+ /** Query params (GET) or JSON body (everything else). */
59
+ params?: Record<string, unknown>
60
+ /** Per-request headers, merged last. */
61
+ headers?: Record<string, string>
62
+ /** Abort signal for the fetch. */
63
+ signal?: AbortSignal
64
+ /** Overrides the configured credentials mode. */
65
+ credentials?: RequestCredentials
66
+ }
67
+
68
+ /** One field of Frappe's JSON error body. */
69
+ interface FrappeResponseBody {
70
+ docs?: unknown
71
+ exc?: string
72
+ exc_type?: string
73
+ message?: unknown
74
+ _server_messages?: string
75
+ _error_message?: string
76
+ }
77
+
78
+ /** Options accepted by {@link RequestError}. */
79
+ export interface RequestErrorOptions {
80
+ status?: number
81
+ response?: Response
82
+ excType?: string
83
+ exc?: string
84
+ messages?: string[]
85
+ }
86
+
87
+ /**
88
+ * What every request failure rejects with: transport errors, non-2xx
89
+ * responses, and an ok response whose body will not parse. Narrow by field
90
+ * (`error.status`, `error.messages`); `messages` is always a `string[]`.
91
+ */
92
+ export class RequestError extends Error {
93
+ readonly status?: number
94
+ readonly response?: Response
95
+ readonly excType?: string
96
+ readonly exc?: string
97
+ readonly messages: string[]
98
+
99
+ constructor(message: string, options: RequestErrorOptions = {}) {
100
+ super(message)
101
+ this.name = 'RequestError'
102
+ this.status = options.status
103
+ this.response = options.response
104
+ this.excType = options.excType
105
+ this.exc = options.exc
106
+ this.messages = options.messages ?? []
107
+ }
108
+ }
109
+
110
+ /** True for a deliberate abort, which is not a resource error. */
111
+ export function isAbortError(error: unknown): boolean {
112
+ return (
113
+ !!error &&
114
+ typeof error === 'object' &&
115
+ (error as { name?: unknown }).name === 'AbortError'
116
+ )
117
+ }
118
+
119
+ /**
120
+ * Coerce anything thrown by `fetch` (or the caller's `transform`) into a
121
+ * {@link RequestError}, preserving the original `name` so `AbortError` stays
122
+ * detectable.
123
+ */
124
+ export function normalizeError(error: unknown): RequestError {
125
+ if (error instanceof RequestError) return error
126
+ const message = error instanceof Error ? error.message : String(error)
127
+ const normalized = new RequestError(message)
128
+ if (error instanceof Error && error.name) normalized.name = error.name
129
+ return normalized
130
+ }
131
+
132
+ function resolveUrl(rawUrl: string): string {
133
+ if (/^https?:\/\//i.test(rawUrl)) return rawUrl
134
+ const path = rawUrl.startsWith('/') ? rawUrl : `/api/method/${rawUrl}`
135
+ const { baseUrl } = defaults
136
+ if (baseUrl && path.startsWith('/')) {
137
+ return baseUrl.replace(/\/+$/, '') + path
138
+ }
139
+ return path
140
+ }
141
+
142
+ function resolveHeaders(options: RequestOptions): Record<string, string> {
143
+ const configHeaders = defaults.headers
144
+ const extra =
145
+ typeof configHeaders === 'function'
146
+ ? configHeaders()
147
+ : (configHeaders ?? {})
148
+ const headers: Record<string, string> = {
149
+ Accept: 'application/json',
150
+ 'Content-Type': 'application/json; charset=utf-8',
151
+ ...extra,
152
+ ...(options.headers ?? {}),
153
+ }
154
+ if (typeof window !== 'undefined' && window.csrf_token) {
155
+ if (window.csrf_token !== '{{ csrf_token }}') {
156
+ headers['X-Frappe-CSRF-Token'] = window.csrf_token
157
+ }
158
+ }
159
+ return headers
160
+ }
161
+
162
+ declare global {
163
+ interface Window {
164
+ csrf_token?: string
165
+ }
166
+ }
167
+
168
+ function appendQuery(url: string, params: Record<string, unknown>): string {
169
+ const search = new URLSearchParams()
170
+ for (const [key, value] of Object.entries(params)) {
171
+ if (value === undefined || value === null) continue
172
+ search.append(
173
+ key,
174
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
175
+ )
176
+ }
177
+ const query = search.toString()
178
+ return query ? `${url}?${query}` : url
179
+ }
180
+
181
+ function isLoginUrl(url: string): boolean {
182
+ const path = url.split(/[?#]/)[0]?.replace(/\/+$/, '') ?? ''
183
+ return path === '/api/method/login' || path.endsWith('/api/method/login')
184
+ }
185
+
186
+ function parseExc(exc: string | undefined): string | undefined {
187
+ if (!exc) return undefined
188
+ try {
189
+ const parsed = JSON.parse(exc) as unknown
190
+ if (Array.isArray(parsed) && parsed.length > 0) return String(parsed[0])
191
+ return exc
192
+ } catch {
193
+ return exc
194
+ }
195
+ }
196
+
197
+ function normalizeMessages(body: FrappeResponseBody): string[] {
198
+ let messages: string[] = []
199
+ if (body._server_messages) {
200
+ try {
201
+ const parsed = JSON.parse(body._server_messages) as unknown
202
+ if (Array.isArray(parsed)) messages = parsed.map(String)
203
+ } catch {
204
+ messages = []
205
+ }
206
+ }
207
+ if (body.message !== undefined)
208
+ messages = messages.concat(String(body.message))
209
+ messages = messages
210
+ .map((message) => {
211
+ try {
212
+ const parsed = JSON.parse(message) as { message?: string }
213
+ return parsed.message ?? message
214
+ } catch {
215
+ return message
216
+ }
217
+ })
218
+ .filter(Boolean)
219
+ if (messages.length === 0) {
220
+ messages = body._error_message
221
+ ? [body._error_message]
222
+ : ['Internal Server Error']
223
+ }
224
+ return messages
225
+ }
226
+
227
+ function reportServerMessages(body: FrappeResponseBody): void {
228
+ if (!body._server_messages) return
229
+ const handler = defaults.onServerMessages
230
+ if (!handler) return
231
+ try {
232
+ const parsed = JSON.parse(body._server_messages) as unknown
233
+ if (Array.isArray(parsed)) handler(parsed.map(String))
234
+ } catch {
235
+ // A malformed `_server_messages` is not worth failing an ok response over.
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Fetch a Frappe method and return its `message`.
241
+ *
242
+ * `url` may be absolute, `/api/method/…`, or a bare dotted method name. GET
243
+ * params become the query string; every other method sends them as a JSON body.
244
+ * A body carrying `docs` (or the login endpoint) is returned whole; otherwise
245
+ * `message` is unwrapped. Failures reject with a {@link RequestError}.
246
+ */
247
+ export async function request<TResponse = unknown>(
248
+ options: RequestOptions,
249
+ ): Promise<TResponse> {
250
+ if (!options.url) {
251
+ throw new RequestError('[request] options.url is required')
252
+ }
253
+ const method = (options.method ?? 'POST').toUpperCase()
254
+ const resolved = resolveUrl(options.url)
255
+ const headers = resolveHeaders(options)
256
+
257
+ let url = resolved
258
+ let body: string | undefined
259
+ if (options.params && method === 'GET') {
260
+ url = appendQuery(resolved, options.params)
261
+ } else if (options.params) {
262
+ body = JSON.stringify(options.params)
263
+ }
264
+
265
+ const credentials =
266
+ options.credentials ??
267
+ defaults.credentials ??
268
+ (defaults.baseUrl && resolved.startsWith(defaults.baseUrl)
269
+ ? 'include'
270
+ : undefined)
271
+
272
+ const fetchImpl = defaults.fetch ?? fetch
273
+ const response = await fetchImpl(url, {
274
+ method,
275
+ headers,
276
+ body,
277
+ signal: options.signal,
278
+ ...(credentials ? { credentials } : {}),
279
+ })
280
+
281
+ if (response.ok) {
282
+ const data = (await response.json()) as FrappeResponseBody
283
+ reportServerMessages(data)
284
+ if (data.docs !== undefined || isLoginUrl(url)) {
285
+ return data as TResponse
286
+ }
287
+ return data.message as TResponse
288
+ }
289
+
290
+ const text = await response.text()
291
+ let errorBody: FrappeResponseBody = {}
292
+ try {
293
+ errorBody = JSON.parse(text) as FrappeResponseBody
294
+ } catch {
295
+ // A non-JSON error body (login HTML, proxy error) still fails as a request.
296
+ }
297
+ const detail = [url, errorBody.exc_type, errorBody._error_message]
298
+ .filter(Boolean)
299
+ .join(' ')
300
+ throw new RequestError(detail || response.statusText || 'Request failed', {
301
+ status: response.status,
302
+ response,
303
+ excType: errorBody.exc_type,
304
+ exc: parseExc(errorBody.exc),
305
+ messages: normalizeMessages(errorBody),
306
+ })
307
+ }
@@ -0,0 +1,135 @@
1
+ import { QueryClient } from '@tanstack/solid-query'
2
+ import { createSignal } from 'solid-js'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { connectRealtime } from './connect-realtime'
5
+ import type { Realtime } from './realtime'
6
+
7
+ /** A socket-shaped test double: `emit` stands in for a server push. */
8
+ function fakeRealtime() {
9
+ const handlers = new Map<string, Set<(data: unknown) => void>>()
10
+ const [connected, setConnected] = createSignal(false)
11
+ const socket: Realtime = {
12
+ connected,
13
+ connect: () => setConnected(true),
14
+ on(event, listener) {
15
+ const set = handlers.get(event) ?? new Set()
16
+ set.add(listener)
17
+ handlers.set(event, set)
18
+ return () => set.delete(listener)
19
+ },
20
+ off(event, listener) {
21
+ handlers.get(event)?.delete(listener)
22
+ },
23
+ send: () => {},
24
+ close: () => setConnected(false),
25
+ }
26
+ return {
27
+ socket,
28
+ emit(event: string, data: unknown) {
29
+ for (const listener of handlers.get(event) ?? []) listener(data)
30
+ },
31
+ }
32
+ }
33
+
34
+ function makeClient() {
35
+ const client = new QueryClient({
36
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
37
+ })
38
+ const invalidate = vi.spyOn(client, 'invalidateQueries')
39
+ return { client, invalidate }
40
+ }
41
+
42
+ describe('connectRealtime', () => {
43
+ it('invalidates the doctype prefix for list events', () => {
44
+ const { client, invalidate } = makeClient()
45
+ const { socket, emit } = fakeRealtime()
46
+ connectRealtime(client, { realtime: socket, doctype: 'Task' })
47
+
48
+ emit('list_update', { doctype: 'Task' })
49
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['Task'] })
50
+ })
51
+
52
+ it('invalidates the exact key for doc events', () => {
53
+ const { client, invalidate } = makeClient()
54
+ const { socket, emit } = fakeRealtime()
55
+ connectRealtime(client, { realtime: socket, doctype: 'Task' })
56
+
57
+ emit('list_update', { doctype: 'Task', name: 'TASK-1' })
58
+ expect(invalidate).toHaveBeenCalledWith({
59
+ queryKey: ['Task', 'TASK-1'],
60
+ exact: true,
61
+ })
62
+ })
63
+
64
+ it('honours an explicit event type over the name heuristic', () => {
65
+ const { client, invalidate } = makeClient()
66
+ const { socket, emit } = fakeRealtime()
67
+ connectRealtime(client, { realtime: socket, doctype: 'Task' })
68
+
69
+ emit('list_update', { doctype: 'Task', name: 'TASK-1', type: 'list' })
70
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['Task'] })
71
+ })
72
+
73
+ it('filters by doctype and reports the normalized event', () => {
74
+ const { client, invalidate } = makeClient()
75
+ const { socket, emit } = fakeRealtime()
76
+ const onEvent = vi.fn()
77
+ connectRealtime(client, { realtime: socket, doctype: 'Task' }, { onEvent })
78
+
79
+ emit('list_update', { doctype: 'Invoice', name: 'INV-1' })
80
+ expect(invalidate).not.toHaveBeenCalled()
81
+ expect(onEvent).not.toHaveBeenCalled()
82
+
83
+ emit('list_update', { doctype: 'Task', name: 'TASK-1' })
84
+ expect(onEvent).toHaveBeenCalledWith({
85
+ type: 'doc',
86
+ doctype: 'Task',
87
+ name: 'TASK-1',
88
+ })
89
+ })
90
+
91
+ it('subscribes to the event names the app declares', () => {
92
+ const { client, invalidate } = makeClient()
93
+ const { socket, emit } = fakeRealtime()
94
+ connectRealtime(client, {
95
+ realtime: socket,
96
+ doctype: ['Task', 'Invoice'],
97
+ events: ['doc_update'],
98
+ })
99
+
100
+ emit('list_update', { doctype: 'Task' })
101
+ expect(invalidate).not.toHaveBeenCalled()
102
+ emit('doc_update', { doctype: 'Invoice', name: 'INV-2' })
103
+ expect(invalidate).toHaveBeenCalledWith({
104
+ queryKey: ['Invoice', 'INV-2'],
105
+ exact: true,
106
+ })
107
+ })
108
+
109
+ it('ignores payloads that are not change events', () => {
110
+ const { client, invalidate } = makeClient()
111
+ const { socket, emit } = fakeRealtime()
112
+ const onEvent = vi.fn()
113
+ connectRealtime(client, { realtime: socket, doctype: 'Task' }, { onEvent })
114
+
115
+ emit('list_update', 'ping')
116
+ emit('list_update', { name: 'no-doctype' })
117
+ emit('list_update', null)
118
+ expect(invalidate).not.toHaveBeenCalled()
119
+ expect(onEvent).not.toHaveBeenCalled()
120
+ })
121
+
122
+ it('stops listening on close, leaving the socket itself alone', () => {
123
+ const { client, invalidate } = makeClient()
124
+ const { socket, emit } = fakeRealtime()
125
+ const connection = connectRealtime(client, {
126
+ realtime: socket,
127
+ doctype: 'Task',
128
+ })
129
+
130
+ connection.close()
131
+ emit('list_update', { doctype: 'Task' })
132
+ expect(invalidate).not.toHaveBeenCalled()
133
+ expect(socket.connected()).toBe(false)
134
+ })
135
+ })