@urbicon-ui/sveltekit-utils 7.0.1 → 8.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Small, focused SvelteKit helpers that Urbicon apps share. Zero runtime dependenc
5
5
  Currently shipping:
6
6
 
7
7
  - **URL-state runes** — reactive `useUrlParam` / `useUrlArrayParam` that keep component state in sync with `?query=` parameters
8
- - **Table-query URL sync** — opt-in `?q=…&sort=…&page=…` mirroring for `@urbicon-ui/table` server mode, plus the pure serializers behind it
8
+ - **Table view ↔ URL** — `bindViewToUrl`, the URL home for a `@urbicon-ui/table` view object (`?q=…&sort=…&page=…`), plus the pure serializers for the load path
9
9
  - **Cron runner** — interval-based background fetcher for scheduled server endpoints
10
10
  - **SSE stream reader** — `streamSse`, a spec-correct async-generator client for one-shot POST `text/event-stream` endpoints (LLM relays)
11
11
 
@@ -57,60 +57,60 @@ updateUrlSearchParams({ page: '1', tag: ['a', 'b'] }, { replaceState: true });
57
57
  - URL updates use `goto()` with `replaceState: true`, `noScroll: true`, `keepFocus: true` — suited for filter/pagination UIs, not full page transitions.
58
58
  - `useUrlParam` returns getters (not Svelte stores) so consumers can read the value lazily inside `$derived`/`$effect`.
59
59
 
60
- ## Table Query ↔ URL (`table-query` + `url.svelte`)
60
+ ## Table View ↔ URL (`url.svelte` + `table-view`)
61
61
 
62
- Opt-in URL sync for `@urbicon-ui/table`, in client mode as well as `mode="server"`: the `TableQuery` the table emits (search, sort, page, page size, filters, grouping) is mirrored onto query parameters (`?q=…&sort=…&page=…`), so the view state survives reloads, can be shared as a link, and — unlike `localStorage` — is visible to the server.
62
+ `bindViewToUrl` gives the view object of `@urbicon-ui/table` search, sort, page, page size, filters, grouping the URL as its home: the axes are mirrored onto query parameters (`?q=…&sort=…&page=…`), so the view survives a reload, can be shared as a link, and — unlike `localStorage` — is visible to the server.
63
63
 
64
64
  ```svelte
65
65
  <script lang="ts">
66
- import { Table } from '@urbicon-ui/table';
67
- import { tableQueryToSearchParams } from '@urbicon-ui/sveltekit-utils/table-query';
68
- import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
66
+ import { Table, createTableView } from '@urbicon-ui/table';
67
+ import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
69
68
 
70
- const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
69
+ const view = createTableView({ defaults: { pageSize: 25 } });
70
+ bindViewToUrl(view);
71
71
  </script>
72
72
 
73
- <Table
74
- mode="server"
75
- {columns}
76
- itemsPerPage={25}
77
- query={sync.viewState}
78
- queryFn={async (query, { signal }) => {
79
- sync.syncQuery(query); // mirror the query onto the URL (replaceState)
80
- const res = await fetch(`/api/users?${tableQueryToSearchParams(query)}`, { signal });
81
- const data = await res.json();
82
- return { items: data.results, totalItems: data.total };
83
- }}
84
- />
73
+ <Table {items} {columns} {view} />
85
74
  ```
86
75
 
87
- `viewState` carries **only the axes the URL actually names**, and the table's `query` prop reads it by field presence: a present field controls that axis, an absent one leaves persistence and the `initial*` seeds alone. That is why this is not `initialQuery` — that one describes every axis, including the ones it filled in from the defaults, so a table wired to it ignores `persistenceConfig`, `initialSort`, `initialFilters` and `initialGroupBy` on every URL. `initialQuery` stays what its name says: a complete snapshot, parsed once, to seed a fetch with.
76
+ Both calls belong in the component's initialisation. The init half runs synchronously a `?sort=…` link renders sorted server HTML and the runtime halves are effects: URL navigations apply to the view, the reader's changes reach the URL debounced.
88
77
 
89
- Because `viewState` re-reads the URL rather than capturing it, the browser's back button works: navigating back to a URL without `?sort` returns the table to its unsorted view.
78
+ The second argument is optional; every option has a default:
90
79
 
91
- A controlled axis is not written to `localStorage` by default, so a visit that arrives without query params starts clean. For a business table where "my filters are still there tomorrow" is expected, pair the sync with `persistenceConfig={{ tableId: '…', persistControlled: true }}` — the table then stores what the reader themselves changed (never what a shared link brought), and hands it back on a bare visit. Reading order is unaffected: the URL still wins.
80
+ | Option | Default | Effect |
81
+ | ----------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `axes` | all six | Which axes this binding manages. An unbound axis never reaches the URL, whatever the view holds. |
83
+ | `debounceMs` | `300` | Delay before a view change is written to the URL. |
84
+ | `replaceState` | `true` | Replace the current history entry instead of pushing one, so rapid sort/filter/page edits do not flood the back button. |
85
+ | `prefix` | `''` | Key namespace (`prefix: 't_'` → `?t_q=…&t_page=…`) for a second bound table on the same page. |
86
+ | `reflectExternal` | `false` | Mirror an externally applied value (a storage seed) into the URL immediately. Off by default: the address bar does not change without reader interaction, and the seed reaches the URL with the first one anyway. |
92
87
 
93
- With manual control (`onQueryChange` instead of `queryFn`), pass `sync.syncQuery` directly note that `onQueryChange` does not fire when `queryFn` is set, which is why the managed variant calls it inside `queryFn`.
88
+ Because the binding re-reads the URL rather than capturing it, the browser's back button works: navigating back to a URL that no longer names `?sort` returns the table to its default sort.
94
89
 
95
- The pure serializers live under `@urbicon-ui/sveltekit-utils/table-query` and work without SvelteKit — e.g. to parse the initial query in a server `load` and fetch the first page during SSR:
90
+ The pure serializers work without SvelteKit — e.g. to parse the incoming query in a server `load` and fetch the first page during SSR. Use `searchParamsToViewSnapshot` from `./table-view`: it takes the *same* defaults object the component hands `createTableView`, so the server cannot resolve an absent param differently from the client, and it hands back the very shape a managed `source.query` receives.
96
91
 
97
92
  ```typescript
93
+ // view-defaults.ts — imported by both the component and the load function
94
+ export const userView = { pageSize: 25, sort: { column: 'joined', direction: 'desc' } };
95
+
98
96
  // +page.server.ts
99
- import { searchParamsToTableQuery } from '@urbicon-ui/sveltekit-utils/table-query';
97
+ import { searchParamsToViewSnapshot } from '@urbicon-ui/sveltekit-utils/table-view';
98
+ import { userView } from './view-defaults';
100
99
 
101
- export const load = async ({ url }) => {
102
- const query = searchParamsToTableQuery(url.searchParams, { defaults: { itemsPerPage: 25 } });
103
- return { initialResult: await fetchUsers(query) };
104
- };
100
+ export const load = async ({ url }) => ({
101
+ initialResult: await fetchUsers(searchParamsToViewSnapshot(url.searchParams, userView))
102
+ });
105
103
  ```
106
104
 
105
+ The `./table-query` subpath that used to hold a second copy of this codec — same URL scheme, wire-vocabulary spellings, no field for a default filter set — retired with the vocabulary split it served (#162).
106
+
107
107
  **Design notes**
108
108
 
109
- - **Default elision** — values equal to `defaults` are not written; a table in its default state leaves the URL clean. Set `defaults` to the table's initial props (`itemsPerPage`, `initialPage`, `initialGroupBy`, and `sortColumn`/`sortDirection` when the table ships a baked-in `initialSort`) so the elision baseline matches the state the table starts in.
110
- - **Read tolerant, write strict** — unparsable params fall back to the defaults and malformed `filter` entries are skipped; serializing a structurally invalid query (non-positive page, unknown operator) throws instead of writing corrupt state.
111
- - **Namespacing** — `prefix: 't_'` scopes all keys (`?t_q=…`) for multiple synced tables on one page; unrelated params are always preserved.
112
- - **Types** — `TableQueryParams` is a structural mirror of the table's `TableQuery` (no dependency on `@urbicon-ui/table`; a parity test in the table package guards against drift).
113
- - **Seeding, the variant without `query`** every axis the URL carries can also be handed to the table as a seed: `initialPage`, `initialGroupBy`, `initialSort`, `initialFilters` (plus a controlled `searchTerm` with an `onSearchTermChange` write-back). The seeds land before the table's first query emission, so a shared URL's sort/filter params survive it the header indicator and filter chips show the URL state instead of the first emission wiping it. Its precedence is the **inverse** of `query`'s, and that is the reason to prefer `query`: an `initial*` prop seeds only an axis `persistenceConfig` has nothing stored for, so with both active a persisted sort/filter/group beats the link including a persisted _empty_ value (the user cleared that axis once, so it stays cleared). The seeds do reach the server-rendered HTML — they are applied during construction, which runs under SSR too (measured: `initialSort`, `initialFilters`, `initialGroupBy` and `initialPage` all land in the server markup; only a controlled `searchTerm`, applied in an effect, does not). What they lack is the per-axis precedence above and any reaction to the back button, since a seed is read once. Use them only where the axis must not be controlled; otherwise `query={sync.viewState}` (+ `persistControlled` when the reader's own state should outlive the link).
109
+ - **Default elision** — an axis whose value equals its default is not written; a table in its default state leaves the URL clean. The baseline *is* `view.defaults`, read off the object the binding decorates, so there is no second copy of the defaults to keep in step with the table's own.
110
+ - **Read tolerant, write strict** — an unparsable value on a param the URL actually carries falls back to that axis' default, and malformed `filter` entries are skipped individually. `assertValidViewSnapshot` is the strict half: it throws on a structurally invalid view (non-positive page, unknown operator) instead of writing corrupt state, and `applyViewToSearchParams` calls it. `viewSnapshotToSearchParams` deliberately does not — it runs inside the binding on every view change, where a throw would cost the page rather than the URL.
111
+ - **Namespacing** — `prefix: 't_'` scopes all keys (`?t_q=…`) for multiple bound tables on one page; unrelated params are always preserved. Two prefixless bindings would manage the same keys, so that throws at registration instead of producing a link that loads the wrong table.
112
+ - **One writer per page** — every binding submits into one coalescing URL writer, so two tables land in a single navigation, each replacing only its own keys. A landing URL the writer itself sent is not applied back onto the view: an edit made while that navigation was in flight survives instead of being overwritten by the URL it raced.
113
+ - **Types** — `TableViewLike`, `TableViewSnapshot` and `TableViewFilter` mirror the table's view object structurally, so this package carries no dependency on `@urbicon-ui/table`. A parity test in the table package (`viewMirror.parity.test.ts`) pins the mirror: the shapes must stay mutually assignable, and the real `TableView` must satisfy `TableViewLike`, which is the entire mechanism by which this binding decorates a view it never imports.
114
114
 
115
115
  ## Cron Runner (`cron`)
116
116
 
@@ -207,13 +207,15 @@ export const POST = async ({ request }) => {
207
207
 
208
208
  ## Exports
209
209
 
210
- | Subpath | Contents |
211
- | --------------- | ------------------------------------------------------------------------------------------------------------------ |
212
- | `.` | Barrel of all modules |
213
- | `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `createTableQueryUrlSync`, types |
214
- | `./table-query` | `tableQueryToSearchParams`, `searchParamsToTableQuery`, `applyTableQueryToSearchParams`, `TableQueryParams`, types |
215
- | `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
216
- | `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
210
+ | Subpath | Contents |
211
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
212
+ | `.` | Barrel of all modules |
213
+ | `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `bindViewToUrl`, types |
214
+ | `./table-view` | `searchParamsToViewSnapshot`, `searchParamsToViewPartial`, `viewSnapshotToSearchParams`, `applyViewToSearchParams`, `assertValidViewSnapshot`, `viewAxesNamedBy`, `viewAxisKeys`, `TABLE_VIEW_AXES`, `TABLE_VIEW_FILTER_OPERATORS`, `TableViewLike`, types |
215
+ | `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
216
+ | `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
217
+
218
+ `bindViewToUrl` lives in its own module (`view-binding.svelte.ts`) and is re-exported from `./url.svelte`, which is its documented import path — it has no subpath of its own. `./table-view` is SvelteKit-free (it touches no `$app/*`), which is what lets a `load` function and a plain test use it; `./url.svelte` is the half that needs the router.
217
219
 
218
220
  ## Development
219
221
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from './cron.js';
2
2
  export * from './sse.js';
3
- export * from './table-query.js';
3
+ export * from './table-view.js';
4
4
  export * from './url.svelte.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from './cron.js';
2
2
  export * from './sse.js';
3
- export * from './table-query.js';
3
+ export * from './table-view.js';
4
4
  export * from './url.svelte.js';
@@ -0,0 +1,163 @@
1
+ /**
2
+ * URL (de)serialization for the v8 table view vocabulary (`TableView` from
3
+ * `@urbicon-ui/table`: search, sort, page, pageSize, filters, groupBy).
4
+ *
5
+ * The types in this module are a **structural mirror** of the table package's
6
+ * view types — deliberately not imported, so this package carries no
7
+ * dependency on `@urbicon-ui/table`. A type-parity test over there guards the
8
+ * shapes against drift; `bindViewToUrl` accepts any object shaped like
9
+ * {@link TableViewLike}, which the real `TableView` is.
10
+ *
11
+ * The key scheme is the shipped one (`q`, `page`, `size`, `sort`, `dir`,
12
+ * `group`, `filter`) — deep links written for v7 keep parsing. The write
13
+ * side adds one compatible extension: an empty `filter=` marker, analogous
14
+ * to `sort=`, so a cleared filter set elides like every other axis (the
15
+ * shipped read side already tolerated it).
16
+ */
17
+ /**
18
+ * Filter operators supported by the table. Mirrors `FilterOperator` from
19
+ * `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
20
+ * params from the URL.
21
+ */
22
+ export declare const TABLE_VIEW_FILTER_OPERATORS: readonly ["contains", "equals", "startsWith", "endsWith", "greaterThan", "lessThan"];
23
+ /** Filter operator of a view filter. Mirrors `@urbicon-ui/table`. */
24
+ export type TableViewFilterOperator = (typeof TABLE_VIEW_FILTER_OPERATORS)[number];
25
+ /** Single column filter on the `filters` axis. Mirrors `Filter` from `@urbicon-ui/table`. */
26
+ export interface TableViewFilter {
27
+ /** Column ID the filter applies to. */
28
+ column: string;
29
+ /** Filter operator. */
30
+ operator: TableViewFilterOperator;
31
+ /** Filter value (always a string, numeric operators convert internally). */
32
+ value: string;
33
+ }
34
+ /** One of the six view axes. Mirrors `ViewAxis` from `@urbicon-ui/table`. */
35
+ export type TableViewAxis = 'search' | 'sort' | 'page' | 'pageSize' | 'filters' | 'groupBy';
36
+ /** All six view axes, in vocabulary order. */
37
+ export declare const TABLE_VIEW_AXES: readonly TableViewAxis[];
38
+ /** Sort state of a view. Mirrors `ViewSort` from `@urbicon-ui/table`. */
39
+ export interface TableViewSort {
40
+ /** Column ID to sort by. */
41
+ column: string;
42
+ /** Sort direction. */
43
+ direction: 'asc' | 'desc';
44
+ }
45
+ /**
46
+ * A fully resolved view state — never `undefined` anywhere. Mirrors
47
+ * `TableViewSnapshot` from `@urbicon-ui/table`.
48
+ */
49
+ export interface TableViewSnapshot {
50
+ search: string;
51
+ sort: TableViewSort | null;
52
+ page: number;
53
+ pageSize: number;
54
+ filters: TableViewFilter[];
55
+ groupBy: string | null;
56
+ }
57
+ /**
58
+ * The surface {@link bindViewToUrl} needs from a view object — a structural
59
+ * mirror of the table package's `TableView` class. Field reads are reactive,
60
+ * field writes count as the reader's own change; `applyExternal` is the
61
+ * binding write surface, `claimAxes`/`releaseAxes` the fail-loud composition
62
+ * registry, and `originOf` the per-axis (revision, origin) bookkeeping the
63
+ * bindings decide by.
64
+ */
65
+ export interface TableViewLike {
66
+ readonly defaults: TableViewSnapshot;
67
+ search: string;
68
+ sort: TableViewSort | null;
69
+ page: number;
70
+ pageSize: number;
71
+ filters: TableViewFilter[];
72
+ groupBy: string | null;
73
+ applyExternal(partial: Partial<TableViewSnapshot>, origin: 'external' | 'system'): void;
74
+ claimAxes(kind: 'url' | 'storage', axes: readonly TableViewAxis[]): void;
75
+ releaseAxes(kind: 'url' | 'storage', axes: readonly TableViewAxis[]): void;
76
+ markInitApplied(axes: readonly TableViewAxis[]): void;
77
+ wasInitApplied(axis: TableViewAxis): boolean;
78
+ originOf(axis: TableViewAxis): {
79
+ revision: number;
80
+ origin: 'user' | 'external' | 'system' | 'init';
81
+ };
82
+ snapshot(): TableViewSnapshot;
83
+ }
84
+ /** The URL keys a set of axes owns, with the configured prefix applied. */
85
+ export declare function viewAxisKeys(axes: readonly TableViewAxis[], prefix?: string): string[];
86
+ /** The axes a URL names — presence only for params it actually carries. */
87
+ export declare function viewAxesNamedBy(sp: URLSearchParams, prefix?: string): TableViewAxis[];
88
+ /**
89
+ * Parse search params into a **partial** view snapshot — a key per axis the
90
+ * URL actually carries, and nothing else. Read tolerant per key: an
91
+ * unparsable value on a *present* key falls back to the configured default
92
+ * for that axis (the key was present, so the axis stays claimed), and
93
+ * malformed filter entries are skipped individually.
94
+ */
95
+ export declare function searchParamsToViewPartial(sp: URLSearchParams, defaults: Pick<TableViewSnapshot, 'page' | 'pageSize'>, prefix?: string): Partial<TableViewSnapshot>;
96
+ /**
97
+ * Resolve a full view snapshot from search params: every axis the URL names
98
+ * comes from the URL, every other one from `defaults` — the same resolution
99
+ * the URL binding performs at init, for code that has no view (a server
100
+ * `load`).
101
+ *
102
+ * This is also what a server `load` hands its fetch. Since v9 the view and
103
+ * the query speak one vocabulary (#162), so there is nothing to project on
104
+ * the way out: the object below is the same shape a managed `source.query`
105
+ * receives. The `searchParamsToViewQuery` / `viewSnapshotToTableQuery` pair
106
+ * that used to do the projecting were identity functions once the names
107
+ * agreed, and are gone.
108
+ *
109
+ * The `defaults` argument is the point: it takes the very object
110
+ * `createTableView({ defaults })` takes, so the server cannot resolve an
111
+ * absent param differently from the client — and a default filter set is
112
+ * expressible, which the old wire-vocabulary baseline could not manage no
113
+ * matter how it was written (#157 finding 2).
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * // shared with the component that calls createTableView({ defaults })
118
+ * export const invoiceView = { pageSize: 25, sort: { column: 'date', direction: 'desc' } };
119
+ *
120
+ * export const load = async ({ url }) => ({
121
+ * initialResult: await fetchInvoices(searchParamsToViewSnapshot(url.searchParams, invoiceView))
122
+ * });
123
+ * ```
124
+ */
125
+ export declare function searchParamsToViewSnapshot(sp: URLSearchParams, defaults?: Partial<TableViewSnapshot>, prefix?: string): TableViewSnapshot;
126
+ /**
127
+ * Serialize a snapshot, eliding every axis that equals the defaults — the
128
+ * elision baseline *is* the view's defaults, structurally. `axes` restricts
129
+ * the output to a binding's own axes: an unbound axis never reaches the URL,
130
+ * no matter what the view holds.
131
+ */
132
+ export declare function viewSnapshotToSearchParams(snapshot: TableViewSnapshot, defaults: TableViewSnapshot, axes?: readonly TableViewAxis[], prefix?: string): URLSearchParams;
133
+ /**
134
+ * Write-side validation: never serialize structurally invalid state.
135
+ *
136
+ * The strict half of the module's read-tolerant / write-strict contract, and
137
+ * deliberately NOT called by {@link viewSnapshotToSearchParams}: that one runs
138
+ * inside the URL binding on every view change, where a throw would take the
139
+ * whole table down over a `view.page = 0` a consumer wrote. Serializing a bad
140
+ * page there costs a wrong URL; throwing there costs the page.
141
+ *
142
+ * @throws TypeError when an axis holds a value the URL scheme cannot mean.
143
+ */
144
+ export declare function assertValidViewSnapshot(snapshot: TableViewSnapshot): void;
145
+ /**
146
+ * Merge a view into existing search params: every key the given axes own is
147
+ * replaced by the serialized snapshot, and every other param is preserved
148
+ * untouched. Keys whose axis returned to its default are removed (the same
149
+ * elision {@link viewSnapshotToSearchParams} applies).
150
+ *
151
+ * This is the one thing the axis-scoped serializer cannot do on its own —
152
+ * everything else the retired `./table-query` module offered was the same
153
+ * codec under wire-vocabulary names (#162).
154
+ *
155
+ * @param existing - Current search params (not mutated — a copy is returned).
156
+ * @param snapshot - The view state to write.
157
+ * @param defaults - Elision baseline, structurally the view's own defaults.
158
+ * @param axes - Axes to write; every other axis is left alone in `existing`.
159
+ * @param prefix - Key prefix, to namespace multiple synced tables on a page.
160
+ * @returns New `URLSearchParams` with the view applied.
161
+ * @throws TypeError when the snapshot is structurally invalid (write strict).
162
+ */
163
+ export declare function applyViewToSearchParams(existing: URLSearchParams, snapshot: TableViewSnapshot, defaults: TableViewSnapshot, axes?: readonly TableViewAxis[], prefix?: string): URLSearchParams;
@@ -0,0 +1,312 @@
1
+ /**
2
+ * URL (de)serialization for the v8 table view vocabulary (`TableView` from
3
+ * `@urbicon-ui/table`: search, sort, page, pageSize, filters, groupBy).
4
+ *
5
+ * The types in this module are a **structural mirror** of the table package's
6
+ * view types — deliberately not imported, so this package carries no
7
+ * dependency on `@urbicon-ui/table`. A type-parity test over there guards the
8
+ * shapes against drift; `bindViewToUrl` accepts any object shaped like
9
+ * {@link TableViewLike}, which the real `TableView` is.
10
+ *
11
+ * The key scheme is the shipped one (`q`, `page`, `size`, `sort`, `dir`,
12
+ * `group`, `filter`) — deep links written for v7 keep parsing. The write
13
+ * side adds one compatible extension: an empty `filter=` marker, analogous
14
+ * to `sort=`, so a cleared filter set elides like every other axis (the
15
+ * shipped read side already tolerated it).
16
+ */
17
+ /**
18
+ * Filter operators supported by the table. Mirrors `FilterOperator` from
19
+ * `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
20
+ * params from the URL.
21
+ */
22
+ export const TABLE_VIEW_FILTER_OPERATORS = [
23
+ 'contains',
24
+ 'equals',
25
+ 'startsWith',
26
+ 'endsWith',
27
+ 'greaterThan',
28
+ 'lessThan'
29
+ ];
30
+ /** All six view axes, in vocabulary order. */
31
+ export const TABLE_VIEW_AXES = [
32
+ 'search',
33
+ 'sort',
34
+ 'page',
35
+ 'pageSize',
36
+ 'filters',
37
+ 'groupBy'
38
+ ];
39
+ /** URL keys per axis, unprefixed — the §3.2 key scheme, grouped by owning axis. */
40
+ const AXIS_KEYS = {
41
+ search: ['q'],
42
+ sort: ['sort', 'dir'],
43
+ page: ['page'],
44
+ pageSize: ['size'],
45
+ filters: ['filter'],
46
+ groupBy: ['group']
47
+ };
48
+ /** The URL keys a set of axes owns, with the configured prefix applied. */
49
+ export function viewAxisKeys(axes, prefix = '') {
50
+ return axes.flatMap((axis) => AXIS_KEYS[axis].map((key) => `${prefix}${key}`));
51
+ }
52
+ function isFilterOperator(value) {
53
+ return TABLE_VIEW_FILTER_OPERATORS.includes(value);
54
+ }
55
+ /** Per-entry tolerance: a malformed entry becomes null and the caller skips it. */
56
+ function parseFilterParam(raw) {
57
+ const parts = raw.split(':');
58
+ if (parts.length !== 3)
59
+ return null;
60
+ const [encodedColumn, operator, encodedValue] = parts;
61
+ if (!isFilterOperator(operator))
62
+ return null;
63
+ try {
64
+ const column = decodeURIComponent(encodedColumn);
65
+ if (!column)
66
+ return null;
67
+ return { column, operator, value: decodeURIComponent(encodedValue) };
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /** The axes a URL names — presence only for params it actually carries. */
74
+ export function viewAxesNamedBy(sp, prefix = '') {
75
+ const axes = [];
76
+ if (sp.get(`${prefix}q`) !== null)
77
+ axes.push('search');
78
+ if (sp.get(`${prefix}sort`) !== null)
79
+ axes.push('sort');
80
+ if (sp.get(`${prefix}page`) !== null)
81
+ axes.push('page');
82
+ if (sp.get(`${prefix}size`) !== null)
83
+ axes.push('pageSize');
84
+ if (sp.getAll(`${prefix}filter`).length > 0)
85
+ axes.push('filters');
86
+ if (sp.get(`${prefix}group`) !== null)
87
+ axes.push('groupBy');
88
+ return axes;
89
+ }
90
+ /**
91
+ * Parse search params into a **partial** view snapshot — a key per axis the
92
+ * URL actually carries, and nothing else. Read tolerant per key: an
93
+ * unparsable value on a *present* key falls back to the configured default
94
+ * for that axis (the key was present, so the axis stays claimed), and
95
+ * malformed filter entries are skipped individually.
96
+ */
97
+ export function searchParamsToViewPartial(sp, defaults, prefix = '') {
98
+ const partial = {};
99
+ const rawSearch = sp.get(`${prefix}q`);
100
+ if (rawSearch !== null)
101
+ partial.search = rawSearch;
102
+ const rawSort = sp.get(`${prefix}sort`);
103
+ if (rawSort !== null) {
104
+ partial.sort =
105
+ rawSort === ''
106
+ ? null // `sort: null` — "unsorted" is a value, not a sentinel
107
+ : { column: rawSort, direction: sp.get(`${prefix}dir`) === 'desc' ? 'desc' : 'asc' };
108
+ }
109
+ const rawPage = sp.get(`${prefix}page`);
110
+ if (rawPage !== null && /^\d+$/.test(rawPage) && Number(rawPage) >= 1) {
111
+ partial.page = Number(rawPage);
112
+ }
113
+ else if (rawPage !== null) {
114
+ partial.page = defaults.page;
115
+ }
116
+ const rawSize = sp.get(`${prefix}size`);
117
+ if (rawSize !== null && /^\d+$/.test(rawSize) && Number(rawSize) >= 1) {
118
+ partial.pageSize = Number(rawSize);
119
+ }
120
+ else if (rawSize !== null) {
121
+ partial.pageSize = defaults.pageSize;
122
+ }
123
+ // `filter=` (empty marker) and `filter=a:contains:b` both claim the axis;
124
+ // the empty marker claims it as *empty* — the compatible format extension.
125
+ const rawFilters = sp.getAll(`${prefix}filter`);
126
+ if (rawFilters.length > 0) {
127
+ partial.filters = rawFilters
128
+ .map(parseFilterParam)
129
+ .filter((f) => f !== null);
130
+ }
131
+ const rawGroup = sp.get(`${prefix}group`);
132
+ if (rawGroup !== null)
133
+ partial.groupBy = rawGroup === '' ? null : rawGroup;
134
+ return partial;
135
+ }
136
+ /**
137
+ * Resolve a full view snapshot from search params: every axis the URL names
138
+ * comes from the URL, every other one from `defaults` — the same resolution
139
+ * the URL binding performs at init, for code that has no view (a server
140
+ * `load`).
141
+ *
142
+ * This is also what a server `load` hands its fetch. Since v9 the view and
143
+ * the query speak one vocabulary (#162), so there is nothing to project on
144
+ * the way out: the object below is the same shape a managed `source.query`
145
+ * receives. The `searchParamsToViewQuery` / `viewSnapshotToTableQuery` pair
146
+ * that used to do the projecting were identity functions once the names
147
+ * agreed, and are gone.
148
+ *
149
+ * The `defaults` argument is the point: it takes the very object
150
+ * `createTableView({ defaults })` takes, so the server cannot resolve an
151
+ * absent param differently from the client — and a default filter set is
152
+ * expressible, which the old wire-vocabulary baseline could not manage no
153
+ * matter how it was written (#157 finding 2).
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * // shared with the component that calls createTableView({ defaults })
158
+ * export const invoiceView = { pageSize: 25, sort: { column: 'date', direction: 'desc' } };
159
+ *
160
+ * export const load = async ({ url }) => ({
161
+ * initialResult: await fetchInvoices(searchParamsToViewSnapshot(url.searchParams, invoiceView))
162
+ * });
163
+ * ```
164
+ */
165
+ export function searchParamsToViewSnapshot(sp, defaults = {}, prefix = '') {
166
+ const resolved = resolveViewDefaults(defaults);
167
+ return { ...resolved, ...searchParamsToViewPartial(sp, resolved, prefix) };
168
+ }
169
+ /**
170
+ * Fill an unset axis with the table's own default — never `undefined`
171
+ * anywhere.
172
+ *
173
+ * The two composite axes are copied for the same reason `TableView`'s
174
+ * constructor copies them: the documented `load` pattern shares ONE defaults
175
+ * object between the server and the component (a module-scope
176
+ * `export const invoiceView = …`). Handing a caller's array straight back out
177
+ * means a `load` that sorts or normalises the snapshot in place poisons that
178
+ * shared constant for every later request on the same server process.
179
+ */
180
+ function resolveViewDefaults(defaults) {
181
+ return {
182
+ search: defaults.search ?? '',
183
+ sort: defaults.sort ? { ...defaults.sort } : null,
184
+ page: defaults.page ?? 1,
185
+ pageSize: defaults.pageSize ?? 10,
186
+ filters: defaults.filters ? [...defaults.filters] : [],
187
+ groupBy: defaults.groupBy || null
188
+ };
189
+ }
190
+ /**
191
+ * Serialize a snapshot, eliding every axis that equals the defaults — the
192
+ * elision baseline *is* the view's defaults, structurally. `axes` restricts
193
+ * the output to a binding's own axes: an unbound axis never reaches the URL,
194
+ * no matter what the view holds.
195
+ */
196
+ export function viewSnapshotToSearchParams(snapshot, defaults, axes = TABLE_VIEW_AXES, prefix = '') {
197
+ const sp = new URLSearchParams();
198
+ const bound = (axis) => axes.includes(axis);
199
+ if (bound('search') && snapshot.search !== defaults.search)
200
+ sp.set(`${prefix}q`, snapshot.search);
201
+ if (bound('page') && snapshot.page !== defaults.page)
202
+ sp.set(`${prefix}page`, String(snapshot.page));
203
+ if (bound('pageSize') && snapshot.pageSize !== defaults.pageSize)
204
+ sp.set(`${prefix}size`, String(snapshot.pageSize));
205
+ // `bound('sort')` gates BOTH branches — gating only the null-mismatch one
206
+ // let an unbound sort leak into the URL (an operator-precedence slip the
207
+ // spike caught; pinned by the axis-subset test).
208
+ const sortDiffers = bound('sort') &&
209
+ ((snapshot.sort === null) !== (defaults.sort === null) ||
210
+ (snapshot.sort !== null &&
211
+ defaults.sort !== null &&
212
+ (snapshot.sort.column !== defaults.sort.column ||
213
+ snapshot.sort.direction !== defaults.sort.direction)));
214
+ if (sortDiffers) {
215
+ if (snapshot.sort === null) {
216
+ sp.set(`${prefix}sort`, ''); // explicitly unsorted — only reachable when defaults sort
217
+ }
218
+ else {
219
+ sp.set(`${prefix}sort`, snapshot.sort.column);
220
+ if (snapshot.sort.direction === 'desc')
221
+ sp.set(`${prefix}dir`, 'desc');
222
+ }
223
+ }
224
+ if (bound('groupBy') && snapshot.groupBy !== defaults.groupBy)
225
+ sp.set(`${prefix}group`, snapshot.groupBy ?? '');
226
+ const filtersDiffer = bound('filters') &&
227
+ (snapshot.filters.length !== defaults.filters.length ||
228
+ snapshot.filters.some((f, i) => f.column !== defaults.filters[i].column ||
229
+ f.operator !== defaults.filters[i].operator ||
230
+ f.value !== defaults.filters[i].value));
231
+ if (filtersDiffer) {
232
+ if (snapshot.filters.length === 0) {
233
+ // The format extension: an empty marker, analogous to `sort=`, so a
234
+ // cleared filter set elides like every other axis.
235
+ sp.set(`${prefix}filter`, '');
236
+ }
237
+ else {
238
+ for (const filter of snapshot.filters) {
239
+ sp.append(`${prefix}filter`, `${encodeURIComponent(filter.column)}:${filter.operator}:${encodeURIComponent(filter.value)}`);
240
+ }
241
+ }
242
+ }
243
+ return sp;
244
+ }
245
+ /**
246
+ * Write-side validation: never serialize structurally invalid state.
247
+ *
248
+ * The strict half of the module's read-tolerant / write-strict contract, and
249
+ * deliberately NOT called by {@link viewSnapshotToSearchParams}: that one runs
250
+ * inside the URL binding on every view change, where a throw would take the
251
+ * whole table down over a `view.page = 0` a consumer wrote. Serializing a bad
252
+ * page there costs a wrong URL; throwing there costs the page.
253
+ *
254
+ * @throws TypeError when an axis holds a value the URL scheme cannot mean.
255
+ */
256
+ export function assertValidViewSnapshot(snapshot) {
257
+ if (!Number.isSafeInteger(snapshot.page) || snapshot.page < 1) {
258
+ throw new TypeError(`[table-view] page must be a positive integer, got ${snapshot.page}`);
259
+ }
260
+ if (!Number.isSafeInteger(snapshot.pageSize) || snapshot.pageSize < 1) {
261
+ throw new TypeError(`[table-view] pageSize must be a positive integer, got ${snapshot.pageSize}`);
262
+ }
263
+ if (snapshot.sort !== null) {
264
+ if (!snapshot.sort.column) {
265
+ throw new TypeError('[table-view] sort.column must be a non-empty string, or sort must be null');
266
+ }
267
+ if (snapshot.sort.direction !== 'asc' && snapshot.sort.direction !== 'desc') {
268
+ throw new TypeError(`[table-view] sort.direction must be 'asc' or 'desc', got ${String(snapshot.sort.direction)}`);
269
+ }
270
+ }
271
+ if (snapshot.groupBy === '') {
272
+ throw new TypeError("[table-view] groupBy must be a non-empty string or null, got ''");
273
+ }
274
+ for (const filter of snapshot.filters) {
275
+ if (!filter.column) {
276
+ throw new TypeError('[table-view] filter.column must be a non-empty string');
277
+ }
278
+ if (!isFilterOperator(filter.operator)) {
279
+ throw new TypeError(`[table-view] unknown filter operator '${String(filter.operator)}' on column '${filter.column}'`);
280
+ }
281
+ }
282
+ }
283
+ /**
284
+ * Merge a view into existing search params: every key the given axes own is
285
+ * replaced by the serialized snapshot, and every other param is preserved
286
+ * untouched. Keys whose axis returned to its default are removed (the same
287
+ * elision {@link viewSnapshotToSearchParams} applies).
288
+ *
289
+ * This is the one thing the axis-scoped serializer cannot do on its own —
290
+ * everything else the retired `./table-query` module offered was the same
291
+ * codec under wire-vocabulary names (#162).
292
+ *
293
+ * @param existing - Current search params (not mutated — a copy is returned).
294
+ * @param snapshot - The view state to write.
295
+ * @param defaults - Elision baseline, structurally the view's own defaults.
296
+ * @param axes - Axes to write; every other axis is left alone in `existing`.
297
+ * @param prefix - Key prefix, to namespace multiple synced tables on a page.
298
+ * @returns New `URLSearchParams` with the view applied.
299
+ * @throws TypeError when the snapshot is structurally invalid (write strict).
300
+ */
301
+ export function applyViewToSearchParams(existing, snapshot, defaults, axes = TABLE_VIEW_AXES, prefix = '') {
302
+ assertValidViewSnapshot(snapshot);
303
+ const serialized = viewSnapshotToSearchParams(snapshot, defaults, axes, prefix);
304
+ const next = new URLSearchParams(existing);
305
+ for (const key of viewAxisKeys(axes, prefix)) {
306
+ next.delete(key);
307
+ }
308
+ for (const [key, value] of serialized) {
309
+ next.append(key, value);
310
+ }
311
+ return next;
312
+ }