@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,111 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createLocalStore } from './local'
3
+
4
+ // jsdom provides a working `localStorage` (vitest sets an http origin), so the
5
+ // default backend is exercised for real; the memory fallback is tested by
6
+ // passing `storage: null` and by hiding the global.
7
+
8
+ let prefix = 0
9
+ function nextPrefix(): string {
10
+ prefix += 1
11
+ return `test-${prefix}:`
12
+ }
13
+
14
+ beforeEach(() => {
15
+ localStorage.clear()
16
+ })
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllGlobals()
20
+ localStorage.clear()
21
+ })
22
+
23
+ describe('createLocalStore', () => {
24
+ it('round-trips JSON values through localStorage', () => {
25
+ const store = createLocalStore({ prefix: nextPrefix() })
26
+
27
+ store.set('doc', { name: 'T-1', tags: ['a', 'b'] })
28
+ store.set('count', 3)
29
+ store.set('flag', false)
30
+
31
+ expect(store.persistent).toBe(true)
32
+ expect(store.get('doc')).toEqual({ name: 'T-1', tags: ['a', 'b'] })
33
+ expect(store.get('count')).toBe(3)
34
+ expect(store.get('flag')).toBe(false)
35
+ })
36
+
37
+ it('returns null for a missing or unparseable entry', () => {
38
+ const store = createLocalStore<unknown>({ prefix: 'broken:' })
39
+ expect(store.get('nope')).toBe(null)
40
+
41
+ // A value written outside the JSON contract must not throw on read.
42
+ localStorage.setItem('broken:x', 'not json')
43
+ expect(store.get('x')).toBe(null)
44
+ })
45
+
46
+ it('scopes keys, delete, has, keys and clear to its prefix', () => {
47
+ const store = createLocalStore({ prefix: 'mine:' })
48
+ const other = createLocalStore({ prefix: 'other:' })
49
+
50
+ store.set('a', 1)
51
+ store.set('b', 2)
52
+ other.set('a', 9)
53
+
54
+ expect(store.has('a')).toBe(true)
55
+ expect(store.keys().sort()).toEqual(['a', 'b'])
56
+ expect(other.keys()).toEqual(['a'])
57
+
58
+ store.delete('a')
59
+ expect(store.has('a')).toBe(false)
60
+ expect(other.get('a')).toBe(9)
61
+
62
+ store.clear()
63
+ expect(store.keys()).toEqual([])
64
+ expect(other.get('a')).toBe(9)
65
+ })
66
+
67
+ it('persists across store instances that share a prefix', () => {
68
+ const first = createLocalStore<{ v: number }>({ prefix: 'shared:' })
69
+ first.set('k', { v: 1 })
70
+
71
+ const second = createLocalStore<{ v: number }>({ prefix: 'shared:' })
72
+ expect(second.get('k')).toEqual({ v: 1 })
73
+ })
74
+
75
+ it('falls back to memory when storage is explicitly null', () => {
76
+ const store = createLocalStore({ prefix: nextPrefix(), storage: null })
77
+
78
+ expect(store.persistent).toBe(false)
79
+ store.set('k', 'v')
80
+ expect(store.get('k')).toBe('v')
81
+ expect(store.has('k')).toBe(true)
82
+ expect(store.keys()).toEqual(['k'])
83
+ store.clear()
84
+ expect(store.keys()).toEqual([])
85
+ })
86
+
87
+ it('falls back to memory when localStorage is unavailable (SSR)', () => {
88
+ vi.stubGlobal('localStorage', undefined)
89
+ const store = createLocalStore({ prefix: nextPrefix() })
90
+
91
+ expect(store.persistent).toBe(false)
92
+ store.set('k', { v: 1 })
93
+ expect(store.get('k')).toEqual({ v: 1 })
94
+ })
95
+
96
+ it('drops writes when the backing storage throws', () => {
97
+ const throwing = {
98
+ length: 0,
99
+ key: () => null,
100
+ getItem: () => null,
101
+ setItem: () => {
102
+ throw new Error('QuotaExceededError')
103
+ },
104
+ removeItem: () => {},
105
+ } as unknown as Storage
106
+ const store = createLocalStore({ prefix: nextPrefix(), storage: throwing })
107
+
108
+ expect(() => store.set('k', 1)).not.toThrow()
109
+ expect(store.get('k')).toBe(null)
110
+ })
111
+ })
@@ -0,0 +1,155 @@
1
+ /**
2
+ * A `localStorage`-backed key/value store for caching resource data.
3
+ *
4
+ * The synchronous sibling of {@link createIdbStore}: values are JSON-encoded, so
5
+ * anything `JSON.parse(JSON.stringify(value))` round-trips is safe to cache.
6
+ * Every call is guarded for SSR — with no `localStorage` the store silently
7
+ * falls back to an in-memory `Map`, so importing and using it on the server
8
+ * never throws. It is a plain API (no Solid reactivity): a cache is not a view.
9
+ */
10
+
11
+ /** The storage backend a {@link LocalStore} reads and writes. */
12
+ type LocalStoreBackend = Storage | null
13
+
14
+ /** A synchronous, JSON, key/value cache. */
15
+ export interface LocalStore<T = unknown> {
16
+ /** The stored value, or `null` when the key is absent or not JSON. */
17
+ get(key: string): T | null
18
+ /** JSON-encode and persist a value. */
19
+ set(key: string, value: T): void
20
+ /** Remove a key. */
21
+ delete(key: string): void
22
+ /** Whether the key exists. */
23
+ has(key: string): boolean
24
+ /** Every key owned by this store, with the prefix stripped. */
25
+ keys(): string[]
26
+ /** Remove every key owned by this store. */
27
+ clear(): void
28
+ /** False when the store fell back to memory (SSR, or storage disabled). */
29
+ readonly persistent: boolean
30
+ }
31
+
32
+ /** Options for {@link createLocalStore}. */
33
+ export interface LocalStoreOptions {
34
+ /** Key prefix, so several stores can share one `localStorage`. */
35
+ prefix?: string
36
+ /** Storage backend. Defaults to the global `localStorage`. */
37
+ storage?: LocalStoreBackend
38
+ }
39
+
40
+ function defaultStorage(): LocalStoreBackend {
41
+ try {
42
+ return typeof localStorage === 'undefined' ? null : localStorage
43
+ } catch {
44
+ // A sandboxed or disabled `localStorage` throws on access, not on use.
45
+ return null
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Create a synchronous cache over `localStorage`.
51
+ *
52
+ * Keys are namespaced by `prefix` (default `newui:`), so a store owns only the
53
+ * entries it wrote: `keys()` and `clear()` never touch another store's data.
54
+ * A storage that throws (quota exceeded, disabled) degrades to a no-op write
55
+ * rather than failing the caller.
56
+ */
57
+ export function createLocalStore<T = unknown>(
58
+ options: LocalStoreOptions = {},
59
+ ): LocalStore<T> {
60
+ const prefix = options.prefix ?? 'newui:'
61
+ const storage =
62
+ options.storage === undefined ? defaultStorage() : options.storage
63
+ const memory = new Map<string, string>()
64
+
65
+ const scoped = (key: string): string => `${prefix}${key}`
66
+
67
+ function get(key: string): T | null {
68
+ let raw: string | null
69
+ if (storage) {
70
+ try {
71
+ raw = storage.getItem(scoped(key))
72
+ } catch {
73
+ raw = null
74
+ }
75
+ } else {
76
+ raw = memory.get(key) ?? null
77
+ }
78
+ if (raw === null) return null
79
+ try {
80
+ return JSON.parse(raw) as T
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ function set(key: string, value: T): void {
87
+ const raw = JSON.stringify(value)
88
+ if (storage) {
89
+ try {
90
+ storage.setItem(scoped(key), raw)
91
+ } catch {
92
+ // Quota or disabled storage: the write is dropped, not fatal.
93
+ }
94
+ } else {
95
+ memory.set(key, raw)
96
+ }
97
+ }
98
+
99
+ function remove(key: string): void {
100
+ if (storage) {
101
+ try {
102
+ storage.removeItem(scoped(key))
103
+ } catch {
104
+ // Nothing to do — the entry is already unreachable.
105
+ }
106
+ } else {
107
+ memory.delete(key)
108
+ }
109
+ }
110
+
111
+ function has(key: string): boolean {
112
+ if (storage) {
113
+ try {
114
+ return storage.getItem(scoped(key)) !== null
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+ return memory.has(key)
120
+ }
121
+
122
+ function keys(): string[] {
123
+ if (!storage) return [...memory.keys()]
124
+ const owned: string[] = []
125
+ try {
126
+ for (let index = 0; index < storage.length; index++) {
127
+ const key = storage.key(index)
128
+ if (key?.startsWith(prefix)) {
129
+ owned.push(key.slice(prefix.length))
130
+ }
131
+ }
132
+ } catch {
133
+ return []
134
+ }
135
+ return owned
136
+ }
137
+
138
+ function clear(): void {
139
+ if (!storage) {
140
+ memory.clear()
141
+ return
142
+ }
143
+ for (const key of keys()) remove(key)
144
+ }
145
+
146
+ return {
147
+ get,
148
+ set,
149
+ delete: remove,
150
+ has,
151
+ keys,
152
+ clear,
153
+ persistent: storage !== null,
154
+ }
155
+ }
@@ -0,0 +1,331 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createRealtime,
4
+ type RealtimeListener,
5
+ type RealtimeSocket,
6
+ type RealtimeSocketFactory,
7
+ } from './realtime'
8
+
9
+ /** A minimal socket: records listeners, exposes a manual `fire`. */
10
+ function makeSocket() {
11
+ const handlers = new Map<string, Set<RealtimeListener>>()
12
+ const sent: unknown[] = []
13
+ let closed = false
14
+
15
+ const socket: RealtimeSocket = {
16
+ on(event, listener) {
17
+ const set = handlers.get(event) ?? new Set<RealtimeListener>()
18
+ set.add(listener)
19
+ handlers.set(event, set)
20
+ },
21
+ off(event, listener) {
22
+ handlers.get(event)?.delete(listener)
23
+ },
24
+ close() {
25
+ closed = true
26
+ },
27
+ send(data) {
28
+ sent.push(data)
29
+ },
30
+ }
31
+
32
+ return {
33
+ socket,
34
+ fire(event: string, ...args: unknown[]) {
35
+ for (const listener of handlers.get(event) ?? []) listener(...args)
36
+ },
37
+ count(event: string) {
38
+ return handlers.get(event)?.size ?? 0
39
+ },
40
+ sent,
41
+ get closed() {
42
+ return closed
43
+ },
44
+ }
45
+ }
46
+
47
+ type Fake = ReturnType<typeof makeSocket>
48
+
49
+ function makeHarness() {
50
+ const sockets: Fake[] = []
51
+ const factory: RealtimeSocketFactory = () => {
52
+ const next = makeSocket()
53
+ sockets.push(next)
54
+ return next.socket
55
+ }
56
+ return { sockets, factory }
57
+ }
58
+
59
+ function socketAt(sockets: Fake[], index = 0): Fake {
60
+ const socket = sockets[index]
61
+ if (!socket) throw new Error(`No socket at index ${index}`)
62
+ return socket
63
+ }
64
+
65
+ afterEach(() => {
66
+ vi.useRealTimers()
67
+ vi.unstubAllGlobals()
68
+ })
69
+
70
+ describe('createRealtime', () => {
71
+ it('connects on creation and tracks the connected signal', () => {
72
+ const { sockets, factory } = makeHarness()
73
+ const realtime = createRealtime({
74
+ url: 'ws://x',
75
+ factory,
76
+ reconnect: false,
77
+ })
78
+
79
+ expect(sockets).toHaveLength(1)
80
+ expect(realtime.connected()).toBe(false)
81
+
82
+ socketAt(sockets).fire('open')
83
+ expect(realtime.connected()).toBe(true)
84
+
85
+ socketAt(sockets).fire('close')
86
+ expect(realtime.connected()).toBe(false)
87
+ })
88
+
89
+ it('accepts socket.io connect/disconnect lifecycle events', () => {
90
+ const { sockets, factory } = makeHarness()
91
+ const realtime = createRealtime({
92
+ url: 'ws://x',
93
+ factory,
94
+ reconnect: false,
95
+ })
96
+
97
+ socketAt(sockets).fire('connect')
98
+ expect(realtime.connected()).toBe(true)
99
+
100
+ socketAt(sockets).fire('disconnect', 'io server disconnect')
101
+ expect(realtime.connected()).toBe(false)
102
+ })
103
+
104
+ it('reflects a socket that is already connected', () => {
105
+ const socket: RealtimeSocket = {
106
+ connected: true,
107
+ on() {},
108
+ off() {},
109
+ close() {},
110
+ send() {},
111
+ }
112
+ const realtime = createRealtime({
113
+ url: 'ws://x',
114
+ factory: () => socket,
115
+ reconnect: false,
116
+ })
117
+
118
+ expect(realtime.connected()).toBe(true)
119
+ })
120
+
121
+ it('delivers named events and unsubscribes', () => {
122
+ const { sockets, factory } = makeHarness()
123
+ const realtime = createRealtime<{ list_update: { name: string } }>({
124
+ url: 'ws://x',
125
+ factory,
126
+ reconnect: false,
127
+ })
128
+ const received: string[] = []
129
+ const unsubscribe = realtime.on('list_update', (data) =>
130
+ received.push(data.name),
131
+ )
132
+
133
+ socketAt(sockets).fire('list_update', { name: 'T-1' })
134
+ expect(received).toEqual(['T-1'])
135
+ expect(socketAt(sockets).count('list_update')).toBe(1)
136
+
137
+ unsubscribe()
138
+ expect(socketAt(sockets).count('list_update')).toBe(0)
139
+ socketAt(sockets).fire('list_update', { name: 'T-2' })
140
+ expect(received).toEqual(['T-1'])
141
+ })
142
+
143
+ it('off() removes a listener registered with on()', () => {
144
+ const { sockets, factory } = makeHarness()
145
+ const realtime = createRealtime({
146
+ url: 'ws://x',
147
+ factory,
148
+ reconnect: false,
149
+ })
150
+ const listener = vi.fn()
151
+
152
+ realtime.on('evt', listener)
153
+ realtime.off('evt', listener)
154
+ socketAt(sockets).fire('evt', 1)
155
+
156
+ expect(listener).not.toHaveBeenCalled()
157
+ expect(socketAt(sockets).count('evt')).toBe(0)
158
+ })
159
+
160
+ it('replays subscriptions registered before connecting', () => {
161
+ const { sockets, factory } = makeHarness()
162
+ const realtime = createRealtime({
163
+ url: 'ws://x',
164
+ factory,
165
+ auto: false,
166
+ reconnect: false,
167
+ })
168
+ const listener = vi.fn()
169
+ realtime.on('evt', listener)
170
+
171
+ expect(sockets).toHaveLength(0)
172
+ realtime.connect()
173
+ expect(sockets).toHaveLength(1)
174
+ expect(socketAt(sockets).count('evt')).toBe(1)
175
+
176
+ socketAt(sockets).fire('evt', 42)
177
+ expect(listener).toHaveBeenCalledWith(42)
178
+ })
179
+
180
+ it('forwards send to the socket', () => {
181
+ const { sockets, factory } = makeHarness()
182
+ const realtime = createRealtime({
183
+ url: 'ws://x',
184
+ factory,
185
+ reconnect: false,
186
+ })
187
+
188
+ realtime.send({ ping: 1 })
189
+ expect(socketAt(sockets).sent).toEqual([{ ping: 1 }])
190
+ })
191
+
192
+ it('reports lifecycle callbacks', () => {
193
+ const { sockets, factory } = makeHarness()
194
+ const onOpen = vi.fn()
195
+ const onClose = vi.fn()
196
+ const onError = vi.fn()
197
+ createRealtime({
198
+ url: 'ws://x',
199
+ factory,
200
+ reconnect: false,
201
+ onOpen,
202
+ onClose,
203
+ onError,
204
+ })
205
+
206
+ socketAt(sockets).fire('open')
207
+ socketAt(sockets).fire('error', 'boom')
208
+ socketAt(sockets).fire('close', 'bye')
209
+
210
+ expect(onOpen).toHaveBeenCalledOnce()
211
+ expect(onError).toHaveBeenCalledWith('boom')
212
+ expect(onClose).toHaveBeenCalledWith('bye')
213
+ })
214
+
215
+ it('reconnects after an unexpected close, with backoff', () => {
216
+ vi.useFakeTimers()
217
+ const { sockets, factory } = makeHarness()
218
+ const realtime = createRealtime({
219
+ url: 'ws://x',
220
+ factory,
221
+ reconnectDelay: 100,
222
+ maxReconnectDelay: 100,
223
+ })
224
+
225
+ expect(sockets).toHaveLength(1)
226
+ socketAt(sockets).fire('open')
227
+ socketAt(sockets).fire('close')
228
+ expect(realtime.connected()).toBe(false)
229
+
230
+ vi.advanceTimersByTime(99)
231
+ expect(sockets).toHaveLength(1)
232
+ vi.advanceTimersByTime(1)
233
+ expect(sockets).toHaveLength(2)
234
+
235
+ // A new connection is replayed the current subscriptions.
236
+ const listener = vi.fn()
237
+ realtime.on('evt', listener)
238
+ socketAt(sockets, 1).fire('evt', 'again')
239
+ expect(listener).toHaveBeenCalledWith('again')
240
+ })
241
+
242
+ it('close() stops reconnecting and closes the socket', () => {
243
+ vi.useFakeTimers()
244
+ const { sockets, factory } = makeHarness()
245
+ const realtime = createRealtime({
246
+ url: 'ws://x',
247
+ factory,
248
+ reconnectDelay: 100,
249
+ })
250
+
251
+ socketAt(sockets).fire('open')
252
+ realtime.close()
253
+ expect(socketAt(sockets).closed).toBe(true)
254
+ expect(realtime.connected()).toBe(false)
255
+
256
+ // A late close event must not reopen a socket the caller closed.
257
+ socketAt(sockets).fire('close')
258
+ vi.advanceTimersByTime(1000)
259
+ expect(sockets).toHaveLength(1)
260
+ })
261
+
262
+ it('is a no-op without a WebSocket or factory (SSR-safe)', () => {
263
+ vi.stubGlobal('WebSocket', undefined)
264
+ const realtime = createRealtime({ url: 'ws://x' })
265
+
266
+ expect(realtime.connected()).toBe(false)
267
+ expect(() => realtime.connect()).not.toThrow()
268
+ expect(() => realtime.send('x')).not.toThrow()
269
+ })
270
+
271
+ it('adapts the native WebSocket by default', () => {
272
+ class FakeWebSocket {
273
+ static OPEN = 1
274
+ static instances: FakeWebSocket[] = []
275
+ readyState = 0
276
+ listeners = new Map<string, Set<EventListener>>()
277
+ sent: unknown[] = []
278
+
279
+ constructor(
280
+ readonly url: string,
281
+ readonly protocols?: string | string[],
282
+ ) {
283
+ FakeWebSocket.instances.push(this)
284
+ }
285
+
286
+ addEventListener(type: string, listener: EventListener) {
287
+ const set = this.listeners.get(type) ?? new Set<EventListener>()
288
+ set.add(listener)
289
+ this.listeners.set(type, set)
290
+ }
291
+
292
+ removeEventListener(type: string, listener: EventListener) {
293
+ this.listeners.get(type)?.delete(listener)
294
+ }
295
+
296
+ close() {
297
+ this.readyState = 3
298
+ }
299
+
300
+ send(data: unknown) {
301
+ this.sent.push(data)
302
+ }
303
+
304
+ emit(type: string, event: unknown) {
305
+ for (const listener of this.listeners.get(type) ?? []) {
306
+ listener(event as Event)
307
+ }
308
+ }
309
+ }
310
+
311
+ vi.stubGlobal('WebSocket', FakeWebSocket)
312
+ const realtime = createRealtime({ url: 'ws://x', reconnect: false })
313
+ const ws = FakeWebSocket.instances[0]
314
+ if (!ws) throw new Error('WebSocket was not constructed')
315
+
316
+ expect(ws.url).toBe('ws://x')
317
+ ws.emit('open', new Event('open'))
318
+ expect(realtime.connected()).toBe(true)
319
+
320
+ realtime.send({ a: 1 })
321
+ expect(ws.sent).toEqual(['{"a":1}'])
322
+
323
+ const received: unknown[] = []
324
+ realtime.on('message', (data) => received.push(data))
325
+ ws.emit('message', { data: 'hello' } as MessageEvent)
326
+ expect(received).toEqual(['hello'])
327
+
328
+ realtime.close()
329
+ expect(ws.readyState).toBe(3)
330
+ })
331
+ })