@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,93 @@
1
+ /**
2
+ * The serialization half of URL state, shared by `createUrlState` and by the
3
+ * higher-level primitives that compose it (`createDashboardState`,
4
+ * `createTablePersist`).
5
+ *
6
+ * `encodeValue` is `createUrlState`'s encoding; a composing primitive needs to
7
+ * know what a key serializes to — to prune params that say nothing — so it
8
+ * lives here instead of being copied. The types live here for the same reason:
9
+ * `url-state.ts` re-exports them, so the public surface is unchanged.
10
+ */
11
+
12
+ /** A value that serializes into a query parameter. */
13
+ export type UrlStateValue =
14
+ | string
15
+ | number
16
+ | boolean
17
+ | readonly UrlStateValue[]
18
+ | { readonly [key: string]: UrlStateValue }
19
+
20
+ /** The router side of {@link createUrlState}. */
21
+ export interface UrlStateAdapter {
22
+ /**
23
+ * The current query string (`"?a=1"` or `"a=1"`). Read **once** on creation,
24
+ * so first paint renders from the URL and a shared link deep-links without
25
+ * a client redirect.
26
+ */
27
+ search(): string
28
+ /** Replace the query string. Must not push a history entry. */
29
+ replace(params: URLSearchParams): void
30
+ }
31
+
32
+ /** JSON with object keys in sorted order, so a round-trip is stable. */
33
+ function stableStringify(value: unknown): string {
34
+ return JSON.stringify(value, (_key, part: unknown) => {
35
+ if (part !== null && typeof part === 'object' && !Array.isArray(part)) {
36
+ const record = part as Record<string, unknown>
37
+ const sorted: Record<string, unknown> = {}
38
+ for (const key of Object.keys(record).sort()) sorted[key] = record[key]
39
+ return sorted
40
+ }
41
+ return part
42
+ })
43
+ }
44
+
45
+ /** Encode one value for its query parameter. Primitives stay as-is. */
46
+ export function encodeValue(value: UrlStateValue): string {
47
+ if (typeof value === 'string') return value
48
+ if (typeof value === 'number' || typeof value === 'boolean') {
49
+ return String(value)
50
+ }
51
+ return stableStringify(value)
52
+ }
53
+
54
+ /**
55
+ * The params an initial record serializes to — what "default" means for a
56
+ * set of keys. A param that still carries its default value says nothing, so
57
+ * it should never reach a shared link.
58
+ */
59
+ export function defaultsFor(
60
+ initial: Record<string, UrlStateValue>,
61
+ ): URLSearchParams {
62
+ const params = new URLSearchParams()
63
+ for (const [key, value] of Object.entries(initial)) {
64
+ params.set(key, encodeValue(value))
65
+ }
66
+ return params
67
+ }
68
+
69
+ /** Drop every param that still says what its default says. Mutates `params`. */
70
+ export function pruneDefaults(
71
+ params: URLSearchParams,
72
+ defaults: URLSearchParams,
73
+ ): URLSearchParams {
74
+ for (const [key, value] of [...defaults]) {
75
+ if (params.get(key) === value) params.delete(key)
76
+ }
77
+ return params
78
+ }
79
+
80
+ /**
81
+ * An adapter wrapper that never writes a param back when it still holds its
82
+ * default. The composing primitive owns the keys; the pruning rule is the
83
+ * "link shares only what is not the default" contract, stated once.
84
+ */
85
+ export function withPruning(
86
+ adapter: UrlStateAdapter,
87
+ defaults: URLSearchParams,
88
+ ): UrlStateAdapter {
89
+ return {
90
+ search: () => adapter.search(),
91
+ replace: (params) => adapter.replace(pruneDefaults(params, defaults)),
92
+ }
93
+ }
@@ -0,0 +1,183 @@
1
+ import { createRoot } from 'solid-js'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import {
4
+ createUrlState,
5
+ type UrlState,
6
+ type UrlStateAdapter,
7
+ type UrlStateOptions,
8
+ type UrlStateValue,
9
+ } from './url-state'
10
+
11
+ function setup<T extends Record<string, UrlStateValue>>(
12
+ initial: T,
13
+ options?: UrlStateOptions,
14
+ ) {
15
+ let dispose!: () => void
16
+ let state!: UrlState<T>
17
+ createRoot((d) => {
18
+ dispose = d
19
+ state = createUrlState(initial, options)
20
+ })
21
+ return { state, dispose }
22
+ }
23
+
24
+ interface FakeAdapter extends UrlStateAdapter {
25
+ /** The params handed to the last `replace`. */
26
+ last(): URLSearchParams
27
+ }
28
+
29
+ function adapter(search = ''): FakeAdapter {
30
+ let last = new URLSearchParams(search)
31
+ const replace = vi.fn((params: URLSearchParams) => {
32
+ last = params
33
+ })
34
+ return {
35
+ search: () => search,
36
+ replace,
37
+ last: () => last,
38
+ }
39
+ }
40
+
41
+ /** Let queued microtasks (the per-tick `replace`) run. */
42
+ async function flushMicrotasks(): Promise<void> {
43
+ await Promise.resolve()
44
+ await Promise.resolve()
45
+ }
46
+
47
+ describe('createUrlState', () => {
48
+ it('behaves as a plain signal without an adapter, touching no URL', async () => {
49
+ const before = window.location.search
50
+ const { state, dispose } = setup({ view: 'list' })
51
+ expect(state.view()).toBe('list')
52
+
53
+ state.set('view', 'board')
54
+ await flushMicrotasks()
55
+ expect(state.view()).toBe('board')
56
+ expect(window.location.search).toBe(before)
57
+ expect(state.params().get('view')).toBe('board')
58
+ dispose()
59
+ })
60
+
61
+ it('reads the URL once on mount, so first paint renders from the link', () => {
62
+ const url = adapter('n=42&tags=["x","y"]&broken=abc')
63
+ const { state, dispose } = setup(
64
+ { n: 1, tags: [] as string[], broken: 7 },
65
+ { adapter: url },
66
+ )
67
+ expect(state.n()).toBe(42)
68
+ expect(state.tags()).toEqual(['x', 'y'])
69
+ // An unparseable value is not a crash: the initial value wins.
70
+ expect(state.broken()).toBe(7)
71
+ dispose()
72
+ })
73
+
74
+ it('serializes primitives as-is and objects with stable key order', async () => {
75
+ const url = adapter()
76
+ const { state, dispose } = setup(
77
+ { page: 1, flag: false as boolean, range: { to: 30, from: 10 } },
78
+ { adapter: url },
79
+ )
80
+ state.set('page', 3)
81
+ state.set('flag', true)
82
+ state.set('range', { to: 99, from: 0 })
83
+ await flushMicrotasks()
84
+
85
+ expect(url.replace).toHaveBeenCalledTimes(1)
86
+ const params = url.last()
87
+ expect(params.get('page')).toBe('3')
88
+ expect(params.get('flag')).toBe('true')
89
+ expect(params.get('range')).toBe('{"from":0,"to":99}')
90
+
91
+ const roundTrip = setup(
92
+ { page: 1, flag: false as boolean, range: { to: 0, from: 0 } },
93
+ { adapter: adapter(params.toString()) },
94
+ )
95
+ expect(roundTrip.state.page()).toBe(3)
96
+ expect(roundTrip.state.flag()).toBe(true)
97
+ expect(roundTrip.state.range()).toEqual({ from: 0, to: 99 })
98
+ dispose()
99
+ roundTrip.dispose()
100
+ })
101
+
102
+ it('treats undefined as absent — removed, never an empty string', async () => {
103
+ const url = adapter('q=hello')
104
+ const { state, dispose } = setup({ q: 'hello' }, { adapter: url })
105
+ state.set('q', undefined)
106
+ await flushMicrotasks()
107
+
108
+ expect(url.last().has('q')).toBe(false)
109
+ expect(url.last().get('q')).toBeNull()
110
+ expect(state.q()).toBe('hello')
111
+ dispose()
112
+ })
113
+
114
+ it('coalesces several writes in one tick into a single replace', async () => {
115
+ const url = adapter()
116
+ const { state, dispose } = setup({ a: 1, b: 2 }, { adapter: url })
117
+ state.set('a', 10)
118
+ state.set('b', 20)
119
+ await flushMicrotasks()
120
+ expect(url.replace).toHaveBeenCalledTimes(1)
121
+ expect(url.last().get('a')).toBe('10')
122
+ expect(url.last().get('b')).toBe('20')
123
+
124
+ state.set('a', 11)
125
+ await flushMicrotasks()
126
+ expect(url.replace).toHaveBeenCalledTimes(2)
127
+ dispose()
128
+ })
129
+
130
+ it('debounces search-like keys, resetting the window on every keystroke', async () => {
131
+ vi.useFakeTimers()
132
+ try {
133
+ const url = adapter()
134
+ const { state, dispose } = setup(
135
+ { q: '', page: 1 },
136
+ { adapter: url, debounce: ['q'] },
137
+ )
138
+ state.set('q', 'a')
139
+ expect(url.replace).not.toHaveBeenCalled()
140
+ vi.advanceTimersByTime(200)
141
+ state.set('q', 'ab')
142
+ expect(url.replace).not.toHaveBeenCalled()
143
+ vi.advanceTimersByTime(200)
144
+ expect(url.replace).not.toHaveBeenCalled()
145
+ vi.advanceTimersByTime(100)
146
+ expect(url.replace).toHaveBeenCalledTimes(1)
147
+ expect(url.last().get('q')).toBe('ab')
148
+
149
+ // A non-debounced key still writes immediately.
150
+ state.set('page', 4)
151
+ await vi.advanceTimersByTimeAsync(0)
152
+ expect(url.last().get('page')).toBe('4')
153
+ dispose()
154
+ } finally {
155
+ vi.useRealTimers()
156
+ }
157
+ })
158
+
159
+ it('honours a custom debounce window', async () => {
160
+ vi.useFakeTimers()
161
+ try {
162
+ const url = adapter()
163
+ const { state, dispose } = setup(
164
+ { q: '' },
165
+ { adapter: url, debounce: ['q'], debounceMs: 50 },
166
+ )
167
+ state.set('q', 'x')
168
+ vi.advanceTimersByTime(50)
169
+ expect(url.last().get('q')).toBe('x')
170
+ dispose()
171
+ } finally {
172
+ vi.useRealTimers()
173
+ }
174
+ })
175
+
176
+ it('exposes params() as a snapshot that cannot corrupt the state', () => {
177
+ const { state, dispose } = setup({ view: 'list' })
178
+ const snapshot = state.params()
179
+ snapshot.set('view', 'tampered')
180
+ expect(state.params().get('view')).toBe('list')
181
+ dispose()
182
+ })
183
+ })
@@ -0,0 +1,168 @@
1
+ import { type Accessor, createSignal, onCleanup, type Setter } from 'solid-js'
2
+ import {
3
+ encodeValue,
4
+ type UrlStateAdapter,
5
+ type UrlStateValue,
6
+ } from './url-serialize'
7
+
8
+ export type { UrlStateAdapter, UrlStateValue } from './url-serialize'
9
+
10
+ /**
11
+ * URL state, router-agnostic by contract.
12
+ *
13
+ * `createUrlState` owns the signals whose values serialize to
14
+ * `URLSearchParams`: what makes a page reproducible by link — filters, view,
15
+ * page index, sort, density, time range. The app supplies the adapter, so the
16
+ * kit never imports a router: Solid Start's `useNavigate`, TanStack Router or
17
+ * a `history.replaceState` shim all satisfy {@link UrlStateAdapter}. Without
18
+ * an adapter the primitive is a plain controllable signal — tests and SSR stay
19
+ * clean, and the URL is simply never written.
20
+ *
21
+ * Writes **replace** the query string, never push (the back button leaves the
22
+ * page, it does not walk a filter history), and concurrent writes coalesce
23
+ * into one `replace` per frame. Search-like keys can be debounced so typing
24
+ * does not spam the history.
25
+ */
26
+
27
+ /** Options for {@link createUrlState}. */
28
+ export interface UrlStateOptions {
29
+ /** The router bridge. Absent means no URL access at all — pure signals. */
30
+ adapter?: UrlStateAdapter
31
+ /** Keys written after a debounce instead of immediately (search-like keys). */
32
+ debounce?: readonly string[]
33
+ /** Debounce window in ms. Defaults to `300`. */
34
+ debounceMs?: number
35
+ }
36
+
37
+ /** The handle {@link createUrlState} returns: one accessor per key. */
38
+ export type UrlState<T extends Record<string, UrlStateValue>> = {
39
+ [K in keyof T]: Accessor<T[K]>
40
+ } & {
41
+ /** Write one key. `undefined` means absent — it is removed, never `""`. */
42
+ set<K extends keyof T>(key: K, value: T[K] | undefined): void
43
+ /** The params every key serializes to right now (for "copy link"). */
44
+ params(): URLSearchParams
45
+ }
46
+
47
+ const DEFAULT_DEBOUNCE_MS = 300
48
+
49
+ /** Decode one raw param back, trusting the initial value's type. */
50
+ function decode(raw: string, initial: UrlStateValue): UrlStateValue {
51
+ if (typeof initial === 'string') return raw
52
+ if (typeof initial === 'number') {
53
+ const value = Number(raw)
54
+ return Number.isFinite(value) ? value : initial
55
+ }
56
+ if (typeof initial === 'boolean') return raw === 'true'
57
+ try {
58
+ return JSON.parse(raw) as UrlStateValue
59
+ } catch {
60
+ return initial
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Controllable signals serialized to `URLSearchParams`.
66
+ *
67
+ * `initial` is what the page renders when the URL says nothing about a key;
68
+ * the URL wins when it does. Keys are the app's, not the table's — `view.sort`
69
+ * rather than `sort` — so one URL can host several surfaces without collisions.
70
+ */
71
+ export function createUrlState<T extends Record<string, UrlStateValue>>(
72
+ initial: T,
73
+ options: UrlStateOptions = {},
74
+ ): UrlState<T> {
75
+ const keys = Object.keys(initial) as (keyof T & string)[]
76
+ const params = new URLSearchParams()
77
+ for (const key of keys) {
78
+ params.set(key, encodeValue(initial[key] as UrlStateValue))
79
+ }
80
+
81
+ const signals = new Map<
82
+ string,
83
+ [Accessor<UrlStateValue>, Setter<UrlStateValue>]
84
+ >()
85
+ const state: Record<string, unknown> = {}
86
+ for (const key of keys) {
87
+ const [get, set] = createSignal<UrlStateValue>(
88
+ initial[key] as UrlStateValue,
89
+ )
90
+ // Storing the accessor reference, not reading it — `state.view` is the
91
+ // signal, so the reactivity rule sees no tracked read to miss.
92
+ // eslint-disable-next-line solid/reactivity
93
+ signals.set(key, [get, set])
94
+ state[key] = get
95
+ }
96
+
97
+ // Read-once on mount: the URL seeds the signals, then signals drive.
98
+ if (options.adapter) {
99
+ try {
100
+ const search = new URLSearchParams(options.adapter.search())
101
+ for (const key of keys) {
102
+ const raw = search.get(key)
103
+ if (raw === null) continue
104
+ const value = decode(raw, initial[key] as UrlStateValue)
105
+ params.set(key, encodeValue(value))
106
+ signals.get(key)?.[1](() => value)
107
+ }
108
+ } catch {
109
+ // An unreadable URL is "absent everywhere": keep the initial values.
110
+ }
111
+ }
112
+
113
+ const debounceKeys = new Set(options.debounce ?? [])
114
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
115
+ let timer: ReturnType<typeof setTimeout> | null = null
116
+ let scheduled = false
117
+ let dirty = false
118
+
119
+ const flush = () => {
120
+ scheduled = false
121
+ dirty = false
122
+ options.adapter?.replace(new URLSearchParams(params))
123
+ }
124
+
125
+ /** Replace-not-push, batched per tick: one `replace` per coalesced write. */
126
+ const schedule = () => {
127
+ dirty = true
128
+ if (!options.adapter || scheduled) return
129
+ scheduled = true
130
+ queueMicrotask(flush)
131
+ }
132
+
133
+ onCleanup(() => {
134
+ if (timer) clearTimeout(timer)
135
+ })
136
+
137
+ const setKey = (
138
+ key: keyof T & string,
139
+ value: UrlStateValue | undefined,
140
+ ): void => {
141
+ const entry = signals.get(key)
142
+ if (!entry) return
143
+ const [, set] = entry
144
+ if (value === undefined) {
145
+ params.delete(key)
146
+ set(() => initial[key] as UrlStateValue)
147
+ } else {
148
+ params.set(key, encodeValue(value))
149
+ set(() => value)
150
+ }
151
+ if (debounceKeys.has(key)) {
152
+ // A search-like key waits out the debounce window before its value
153
+ // reaches the URL, so typing does not spam the history.
154
+ if (timer) clearTimeout(timer)
155
+ dirty = true
156
+ timer = setTimeout(() => {
157
+ timer = null
158
+ if (dirty) flush()
159
+ }, debounceMs)
160
+ } else {
161
+ schedule()
162
+ }
163
+ }
164
+ state['set'] = setKey
165
+
166
+ state['params'] = () => new URLSearchParams(params)
167
+ return state as unknown as UrlState<T>
168
+ }