@urbicon-ui/sveltekit-utils 6.21.3 → 6.23.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,6 +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
9
  - **Cron runner** — interval-based background fetcher for scheduled server endpoints
9
10
 
10
11
  ## Installation
@@ -55,6 +56,56 @@ updateUrlSearchParams({ page: '1', tag: ['a', 'b'] }, { replaceState: true });
55
56
  - URL updates use `goto()` with `replaceState: true`, `noScroll: true`, `keepFocus: true` — suited for filter/pagination UIs, not full page transitions.
56
57
  - `useUrlParam` returns getters (not Svelte stores) so consumers can read the value lazily inside `$derived`/`$effect`.
57
58
 
59
+ ## Table Query ↔ URL (`table-query` + `url.svelte`)
60
+
61
+ Opt-in URL sync for `@urbicon-ui/table` in `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 and can be shared as a link.
62
+
63
+ ```svelte
64
+ <script lang="ts">
65
+ import { Table } from '@urbicon-ui/table';
66
+ import { tableQueryToSearchParams } from '@urbicon-ui/sveltekit-utils/table-query';
67
+ import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
68
+
69
+ const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
70
+ </script>
71
+
72
+ <Table
73
+ mode="server"
74
+ {columns}
75
+ itemsPerPage={25}
76
+ initialPage={sync.initialQuery.page}
77
+ initialGroupBy={sync.initialQuery.groupByKey}
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
+ />
85
+ ```
86
+
87
+ 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
+
89
+ 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
+
91
+ ```typescript
92
+ // +page.server.ts
93
+ import { searchParamsToTableQuery } from '@urbicon-ui/sveltekit-utils/table-query';
94
+
95
+ export const load = async ({ url }) => {
96
+ const query = searchParamsToTableQuery(url.searchParams, { defaults: { itemsPerPage: 25 } });
97
+ return { initialResult: await fetchUsers(query) };
98
+ };
99
+ ```
100
+
101
+ **Design notes**
102
+
103
+ - **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`) so the elision baseline matches the state the table starts in.
104
+ - **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.
105
+ - **Namespacing** — `prefix: 't_'` scopes all keys (`?t_q=…`) for multiple synced tables on one page; unrelated params are always preserved.
106
+ - **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).
107
+ - **Seeding limits** — the table currently has no `initialSort` / initial-filter props, so sort/filter state parsed from the URL can seed your fetch but not the table's header indicators. Once those props exist, `initialQuery` covers them too.
108
+
58
109
  ## Cron Runner (`cron`)
59
110
 
60
111
  Fire HTTP requests against SvelteKit server endpoints on an interval. Pair with a shared-secret header so endpoints can authenticate scheduled calls.
@@ -99,11 +150,12 @@ export const POST = async ({ request }) => {
99
150
 
100
151
  ## Exports
101
152
 
102
- | Subpath | Contents |
103
- | -------------- | ----------------------------------------------------------------------------------- |
104
- | `.` | Barrel of both modules |
105
- | `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, types |
106
- | `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
153
+ | Subpath | Contents |
154
+ | --------------- | ------------------------------------------------------------------------------------------------------------------ |
155
+ | `.` | Barrel of all modules |
156
+ | `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `createTableQueryUrlSync`, types |
157
+ | `./table-query` | `tableQueryToSearchParams`, `searchParamsToTableQuery`, `applyTableQueryToSearchParams`, `TableQueryParams`, types |
158
+ | `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
107
159
 
108
160
  ## Development
109
161
 
package/dist/cron.d.ts CHANGED
@@ -1,18 +1,90 @@
1
+ /** One scheduled job: an endpoint to hit and how often. */
1
2
  export interface CronJob {
3
+ /** Path (appended to {@link CronRunnerConfig.baseUrl}) to fetch on each tick. */
2
4
  path: string;
5
+ /**
6
+ * Interval between fires, in seconds. The first fire happens *after* one
7
+ * interval — there is no leading call at `start()`.
8
+ */
3
9
  intervalSeconds: number;
10
+ /**
11
+ * HTTP method for the request.
12
+ * @default 'POST'
13
+ */
4
14
  method?: 'GET' | 'POST';
5
15
  }
16
+ /** Configuration for {@link createCronRunner}. */
6
17
  export interface CronRunnerConfig {
18
+ /**
19
+ * Shared secret sent on every request in the {@link secretHeader} header, so
20
+ * the target endpoint can distinguish a scheduled call from a public one.
21
+ * Keep it in a private env var; the endpoint compares against the same value.
22
+ */
7
23
  secret: string;
24
+ /**
25
+ * Header name carrying the {@link secret}.
26
+ * @default 'x-cron-secret'
27
+ */
8
28
  secretHeader?: string;
29
+ /**
30
+ * Origin the job paths are resolved against (e.g. `https://app.example.com`).
31
+ * @default 'http://localhost:3000'
32
+ */
9
33
  baseUrl?: string;
34
+ /** Jobs to schedule; each runs on its own independent interval. */
10
35
  jobs: CronJob[];
36
+ /**
37
+ * Called when a job's `fetch` **rejects** (network error, DNS failure, abort).
38
+ * A non-2xx HTTP *response* does not reject `fetch`, so it does **not** reach
39
+ * this hook — the runner is fire-and-forget and never inspects the response.
40
+ * Have the endpoint report its own failures if you need per-run status.
41
+ */
11
42
  onError?: (job: CronJob, error: Error) => void;
12
43
  }
44
+ /** Handle returned by {@link createCronRunner}. */
13
45
  export interface CronRunner {
46
+ /**
47
+ * Arm every job's interval timer. Idempotent — calling `start()` while
48
+ * already running is a no-op (does not double-schedule).
49
+ */
14
50
  start(): void;
51
+ /**
52
+ * Clear every timer so nothing fires again, and flip {@link isRunning} to
53
+ * `false`. Call this on shutdown / HMR teardown to avoid leaked intervals.
54
+ */
15
55
  stop(): void;
56
+ /** Whether the runner is currently armed (between `start()` and `stop()`). */
16
57
  isRunning(): boolean;
17
58
  }
59
+ /**
60
+ * Create a background runner that fires HTTP requests at SvelteKit server
61
+ * endpoints on a fixed interval — a minimal in-process cron for scheduled work
62
+ * (digests, cleanup, cache warming).
63
+ *
64
+ * Deliberately simple: one `setInterval` per job, no drift compensation, no
65
+ * distributed locking, no retry/backoff. Fits a **single-process** deployment;
66
+ * for scale-out point a real scheduler (BullMQ, a platform cron) at the same
67
+ * endpoints instead. The runner starts idle — call `start()` explicitly.
68
+ *
69
+ * @param config - Secret/header, base URL, and the jobs to schedule.
70
+ * @returns A {@link CronRunner} handle (`start` / `stop` / `isRunning`).
71
+ * @example
72
+ * ```typescript
73
+ * // src/lib/server/cron.ts
74
+ * import { createCronRunner } from '@urbicon-ui/sveltekit-utils/cron';
75
+ * import { env } from '$env/dynamic/private';
76
+ *
77
+ * export const cron = createCronRunner({
78
+ * secret: env.CRON_SECRET,
79
+ * baseUrl: env.BASE_URL,
80
+ * jobs: [
81
+ * { path: '/api/cron/send-digest', intervalSeconds: 3600 },
82
+ * { path: '/api/cron/cleanup', intervalSeconds: 900 }
83
+ * ],
84
+ * onError: (job, err) => console.error(`Cron ${job.path} failed`, err)
85
+ * });
86
+ *
87
+ * cron.start();
88
+ * ```
89
+ */
18
90
  export declare function createCronRunner(config: CronRunnerConfig): CronRunner;
package/dist/cron.js CHANGED
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Create a background runner that fires HTTP requests at SvelteKit server
3
+ * endpoints on a fixed interval — a minimal in-process cron for scheduled work
4
+ * (digests, cleanup, cache warming).
5
+ *
6
+ * Deliberately simple: one `setInterval` per job, no drift compensation, no
7
+ * distributed locking, no retry/backoff. Fits a **single-process** deployment;
8
+ * for scale-out point a real scheduler (BullMQ, a platform cron) at the same
9
+ * endpoints instead. The runner starts idle — call `start()` explicitly.
10
+ *
11
+ * @param config - Secret/header, base URL, and the jobs to schedule.
12
+ * @returns A {@link CronRunner} handle (`start` / `stop` / `isRunning`).
13
+ * @example
14
+ * ```typescript
15
+ * // src/lib/server/cron.ts
16
+ * import { createCronRunner } from '@urbicon-ui/sveltekit-utils/cron';
17
+ * import { env } from '$env/dynamic/private';
18
+ *
19
+ * export const cron = createCronRunner({
20
+ * secret: env.CRON_SECRET,
21
+ * baseUrl: env.BASE_URL,
22
+ * jobs: [
23
+ * { path: '/api/cron/send-digest', intervalSeconds: 3600 },
24
+ * { path: '/api/cron/cleanup', intervalSeconds: 900 }
25
+ * ],
26
+ * onError: (job, err) => console.error(`Cron ${job.path} failed`, err)
27
+ * });
28
+ *
29
+ * cron.start();
30
+ * ```
31
+ */
1
32
  export function createCronRunner(config) {
2
33
  const timers = [];
3
34
  let running = false;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './cron';
2
+ export * from './table-query';
2
3
  export * from './url.svelte';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './cron';
2
+ export * from './table-query';
2
3
  export * from './url.svelte';
@@ -0,0 +1,152 @@
1
+ /**
2
+ * URL (de)serialization for the query state a data table emits in server mode
3
+ * (`TableQuery` from `@urbicon-ui/table`: page, page size, sort, search term,
4
+ * column filters, grouping).
5
+ *
6
+ * The types in this module are a **structural mirror** of `TableQuery` — they
7
+ * are deliberately not imported from `@urbicon-ui/table`, so this package
8
+ * carries no dependency on the table package. Any object shaped like
9
+ * `TableQuery` is accepted; a type-parity test in `@urbicon-ui/table` guards
10
+ * the two shapes against drift.
11
+ *
12
+ * Serialization contract:
13
+ * - **Deterministic** — fixed key order (`q`, `page`, `size`, `sort`, `dir`,
14
+ * `group`, `filter`), stable filter order.
15
+ * - **Default elision** — values equal to the resolved defaults are not
16
+ * written; a table in its default state produces an empty query string.
17
+ * - **Read tolerant** — unparsable params fall back to the defaults, and
18
+ * malformed filter entries are skipped.
19
+ * - **Write strict** — a structurally invalid query (non-positive page,
20
+ * unknown filter operator, …) throws instead of writing corrupt state.
21
+ */
22
+ /** Sort direction of a table query. Mirrors `@urbicon-ui/table`. */
23
+ export type TableQuerySortDirection = 'asc' | 'desc';
24
+ /**
25
+ * Filter operators supported by the table. Mirrors `FilterOperator` from
26
+ * `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
27
+ * params from the URL.
28
+ */
29
+ export declare const TABLE_QUERY_FILTER_OPERATORS: readonly ["contains", "equals", "startsWith", "endsWith", "greaterThan", "lessThan"];
30
+ /** Filter operator of a table query filter. Mirrors `@urbicon-ui/table`. */
31
+ export type TableQueryFilterOperator = (typeof TABLE_QUERY_FILTER_OPERATORS)[number];
32
+ /** Single column filter of a table query. Mirrors `Filter` from `@urbicon-ui/table`. */
33
+ export interface TableQueryFilter {
34
+ /** Column ID the filter applies to. */
35
+ column: string;
36
+ /** Filter operator. */
37
+ operator: TableQueryFilterOperator;
38
+ /** Filter value (always a string, numeric operators convert internally). */
39
+ value: string;
40
+ }
41
+ /**
42
+ * Query state of a table in server mode. Structural mirror of `TableQuery`
43
+ * from `@urbicon-ui/table` — the object passed to `queryFn` / `onQueryChange`
44
+ * is directly assignable.
45
+ */
46
+ export interface TableQueryParams {
47
+ /** Current page (1-based). */
48
+ page: number;
49
+ /** Number of items per page. */
50
+ itemsPerPage: number;
51
+ /** Column ID to sort by, or empty string if no sort is active. */
52
+ sortColumn: string;
53
+ /** Sort direction. */
54
+ sortDirection: TableQuerySortDirection;
55
+ /** Full-text search term. */
56
+ searchTerm: string;
57
+ /** Active column filters. */
58
+ activeFilters: TableQueryFilter[];
59
+ /** Column ID for grouping, or null if ungrouped. */
60
+ groupByKey: string | null;
61
+ }
62
+ /**
63
+ * Baseline used for default elision: query values equal to these defaults are
64
+ * omitted from the URL, and missing params parse back to them.
65
+ *
66
+ * Set the defaults to the table's **initial, uncontrolled state** — i.e. the
67
+ * values you pass as props (`itemsPerPage`, `initialPage`, `initialGroupBy`).
68
+ * Unset fields fall back to the table's own defaults (page 1, 10 items per
69
+ * page, no sort, empty search, ungrouped).
70
+ */
71
+ export interface TableQueryDefaults {
72
+ /** Default page. @default 1 */
73
+ page?: number;
74
+ /** Default page size. @default 10 */
75
+ itemsPerPage?: number;
76
+ /** Default sort column ('' = unsorted). @default '' */
77
+ sortColumn?: string;
78
+ /** Default sort direction. @default 'asc' */
79
+ sortDirection?: TableQuerySortDirection;
80
+ /** Default search term. @default '' */
81
+ searchTerm?: string;
82
+ /** Default group key (null = ungrouped). @default null */
83
+ groupByKey?: string | null;
84
+ }
85
+ /** Options shared by the table-query (de)serializers. */
86
+ export interface TableQueryUrlOptions {
87
+ /** Elision baseline — see {@link TableQueryDefaults}. */
88
+ defaults?: TableQueryDefaults;
89
+ /**
90
+ * Prefix for every param key (`prefix: 't_'` → `?t_q=…&t_page=…`). Use it
91
+ * to namespace multiple synced tables on the same page.
92
+ * @default ''
93
+ */
94
+ prefix?: string;
95
+ }
96
+ /**
97
+ * Serialize a table query into `URLSearchParams`, eliding every value that
98
+ * equals the resolved defaults (see {@link TableQueryDefaults}).
99
+ *
100
+ * Key scheme (each key optionally prefixed via `options.prefix`):
101
+ * - `q` — search term
102
+ * - `page` — 1-based page
103
+ * - `size` — items per page
104
+ * - `sort` — sort column; an **empty** `sort=` marks "explicitly unsorted"
105
+ * and is only written when the defaults specify a sort column
106
+ * - `dir` — `desc` (ascending is implied when absent)
107
+ * - `group` — group key; an empty `group=` marks "explicitly ungrouped"
108
+ * - `filter` — repeated, `<column>:<operator>:<value>` with column and value
109
+ * URI-component-encoded so the `:` separators stay unambiguous
110
+ *
111
+ * When `sortColumn` is empty the sort direction is meaningless and is
112
+ * normalized away (it parses back as `'asc'`).
113
+ *
114
+ * Also handy for building the backend request inside `queryFn` — the same
115
+ * scheme works as an API query string.
116
+ *
117
+ * @param query - Query emitted by the table (`TableQuery` is assignable).
118
+ * @param options - Elision defaults + key prefix.
119
+ * @returns Fresh `URLSearchParams` containing only non-default values.
120
+ * @throws TypeError when the query is structurally invalid (write strict).
121
+ */
122
+ export declare function tableQueryToSearchParams(query: TableQueryParams, options?: TableQueryUrlOptions): URLSearchParams;
123
+ /**
124
+ * Parse `URLSearchParams` back into a full table query, filling every missing
125
+ * param from the resolved defaults (see {@link TableQueryDefaults}).
126
+ *
127
+ * Read tolerant: non-numeric `page`/`size` fall back to the defaults, an
128
+ * unknown `dir` becomes `'asc'`, and malformed `filter` entries (wrong shape,
129
+ * unknown operator, broken percent-encoding) are skipped individually.
130
+ *
131
+ * Works anywhere a `URLSearchParams` exists — including `url.searchParams`
132
+ * in a server `load`, to run the initial server-mode fetch during SSR.
133
+ *
134
+ * @param params - Search params to read (not mutated).
135
+ * @param options - Fallback defaults + key prefix.
136
+ * @returns Complete query object (assignable to `TableQuery`).
137
+ */
138
+ export declare function searchParamsToTableQuery(params: URLSearchParams, options?: TableQueryUrlOptions): TableQueryParams;
139
+ /**
140
+ * Merge a table query into existing search params: all managed keys (`q`,
141
+ * `page`, `size`, `sort`, `dir`, `group`, `filter` — with the configured
142
+ * prefix) are replaced by the serialized query, every other param is
143
+ * preserved untouched. Managed keys whose value returned to the default are
144
+ * removed (default elision).
145
+ *
146
+ * @param existing - Current search params (not mutated — a copy is returned).
147
+ * @param query - Query emitted by the table.
148
+ * @param options - Elision defaults + key prefix.
149
+ * @returns New `URLSearchParams` with the query applied.
150
+ * @throws TypeError when the query is structurally invalid (write strict).
151
+ */
152
+ export declare function applyTableQueryToSearchParams(existing: URLSearchParams, query: TableQueryParams, options?: TableQueryUrlOptions): URLSearchParams;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * URL (de)serialization for the query state a data table emits in server mode
3
+ * (`TableQuery` from `@urbicon-ui/table`: page, page size, sort, search term,
4
+ * column filters, grouping).
5
+ *
6
+ * The types in this module are a **structural mirror** of `TableQuery` — they
7
+ * are deliberately not imported from `@urbicon-ui/table`, so this package
8
+ * carries no dependency on the table package. Any object shaped like
9
+ * `TableQuery` is accepted; a type-parity test in `@urbicon-ui/table` guards
10
+ * the two shapes against drift.
11
+ *
12
+ * Serialization contract:
13
+ * - **Deterministic** — fixed key order (`q`, `page`, `size`, `sort`, `dir`,
14
+ * `group`, `filter`), stable filter order.
15
+ * - **Default elision** — values equal to the resolved defaults are not
16
+ * written; a table in its default state produces an empty query string.
17
+ * - **Read tolerant** — unparsable params fall back to the defaults, and
18
+ * malformed filter entries are skipped.
19
+ * - **Write strict** — a structurally invalid query (non-positive page,
20
+ * unknown filter operator, …) throws instead of writing corrupt state.
21
+ */
22
+ /**
23
+ * Filter operators supported by the table. Mirrors `FilterOperator` from
24
+ * `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
25
+ * params from the URL.
26
+ */
27
+ export const TABLE_QUERY_FILTER_OPERATORS = [
28
+ 'contains',
29
+ 'equals',
30
+ 'startsWith',
31
+ 'endsWith',
32
+ 'greaterThan',
33
+ 'lessThan'
34
+ ];
35
+ /** Resolved param key names for one prefix. */
36
+ function paramKeys(prefix) {
37
+ return {
38
+ q: `${prefix}q`,
39
+ page: `${prefix}page`,
40
+ size: `${prefix}size`,
41
+ sort: `${prefix}sort`,
42
+ dir: `${prefix}dir`,
43
+ group: `${prefix}group`,
44
+ filter: `${prefix}filter`
45
+ };
46
+ }
47
+ function resolveDefaults(defaults) {
48
+ return {
49
+ page: defaults?.page ?? 1,
50
+ itemsPerPage: defaults?.itemsPerPage ?? 10,
51
+ sortColumn: defaults?.sortColumn ?? '',
52
+ sortDirection: defaults?.sortDirection ?? 'asc',
53
+ searchTerm: defaults?.searchTerm ?? '',
54
+ groupByKey: defaults?.groupByKey ?? null
55
+ };
56
+ }
57
+ function isFilterOperator(value) {
58
+ return TABLE_QUERY_FILTER_OPERATORS.includes(value);
59
+ }
60
+ /** Write-side validation: never serialize structurally invalid state. */
61
+ function assertValidQuery(query) {
62
+ if (!Number.isSafeInteger(query.page) || query.page < 1) {
63
+ throw new TypeError(`[table-query] page must be a positive integer, got ${query.page}`);
64
+ }
65
+ if (!Number.isSafeInteger(query.itemsPerPage) || query.itemsPerPage < 1) {
66
+ throw new TypeError(`[table-query] itemsPerPage must be a positive integer, got ${query.itemsPerPage}`);
67
+ }
68
+ if (query.sortDirection !== 'asc' && query.sortDirection !== 'desc') {
69
+ throw new TypeError(`[table-query] sortDirection must be 'asc' or 'desc', got ${String(query.sortDirection)}`);
70
+ }
71
+ if (query.groupByKey === '') {
72
+ throw new TypeError("[table-query] groupByKey must be a non-empty string or null, got ''");
73
+ }
74
+ for (const filter of query.activeFilters) {
75
+ if (!filter.column) {
76
+ throw new TypeError('[table-query] filter.column must be a non-empty string');
77
+ }
78
+ if (!isFilterOperator(filter.operator)) {
79
+ throw new TypeError(`[table-query] unknown filter operator '${String(filter.operator)}' on column '${filter.column}'`);
80
+ }
81
+ }
82
+ }
83
+ /** Read-side tolerant integer parsing: anything non-numeric → null. */
84
+ function parsePositiveInt(raw) {
85
+ if (raw === null || !/^\d+$/.test(raw))
86
+ return null;
87
+ const value = Number(raw);
88
+ return Number.isSafeInteger(value) && value >= 1 ? value : null;
89
+ }
90
+ /**
91
+ * Parse one `filter` param value (`<column>:<operator>:<value>`, column and
92
+ * value URI-component-encoded). Returns null for malformed entries — the
93
+ * caller skips them (read tolerant).
94
+ */
95
+ function parseFilterParam(raw) {
96
+ const parts = raw.split(':');
97
+ if (parts.length !== 3)
98
+ return null;
99
+ const [encodedColumn, operator, encodedValue] = parts;
100
+ if (!isFilterOperator(operator))
101
+ return null;
102
+ try {
103
+ const column = decodeURIComponent(encodedColumn);
104
+ if (!column)
105
+ return null;
106
+ return { column, operator, value: decodeURIComponent(encodedValue) };
107
+ }
108
+ catch {
109
+ // Malformed percent-encoding (URIError) — skip the entry.
110
+ return null;
111
+ }
112
+ }
113
+ /**
114
+ * Serialize a table query into `URLSearchParams`, eliding every value that
115
+ * equals the resolved defaults (see {@link TableQueryDefaults}).
116
+ *
117
+ * Key scheme (each key optionally prefixed via `options.prefix`):
118
+ * - `q` — search term
119
+ * - `page` — 1-based page
120
+ * - `size` — items per page
121
+ * - `sort` — sort column; an **empty** `sort=` marks "explicitly unsorted"
122
+ * and is only written when the defaults specify a sort column
123
+ * - `dir` — `desc` (ascending is implied when absent)
124
+ * - `group` — group key; an empty `group=` marks "explicitly ungrouped"
125
+ * - `filter` — repeated, `<column>:<operator>:<value>` with column and value
126
+ * URI-component-encoded so the `:` separators stay unambiguous
127
+ *
128
+ * When `sortColumn` is empty the sort direction is meaningless and is
129
+ * normalized away (it parses back as `'asc'`).
130
+ *
131
+ * Also handy for building the backend request inside `queryFn` — the same
132
+ * scheme works as an API query string.
133
+ *
134
+ * @param query - Query emitted by the table (`TableQuery` is assignable).
135
+ * @param options - Elision defaults + key prefix.
136
+ * @returns Fresh `URLSearchParams` containing only non-default values.
137
+ * @throws TypeError when the query is structurally invalid (write strict).
138
+ */
139
+ export function tableQueryToSearchParams(query, options = {}) {
140
+ assertValidQuery(query);
141
+ const d = resolveDefaults(options.defaults);
142
+ const k = paramKeys(options.prefix ?? '');
143
+ const sp = new URLSearchParams();
144
+ if (query.searchTerm !== d.searchTerm)
145
+ sp.set(k.q, query.searchTerm);
146
+ if (query.page !== d.page)
147
+ sp.set(k.page, String(query.page));
148
+ if (query.itemsPerPage !== d.itemsPerPage)
149
+ sp.set(k.size, String(query.itemsPerPage));
150
+ if (query.sortColumn === '') {
151
+ // Unsorted: only mark explicitly when the defaults would re-introduce a sort.
152
+ if (d.sortColumn !== '')
153
+ sp.set(k.sort, '');
154
+ }
155
+ else if (query.sortColumn !== d.sortColumn || query.sortDirection !== d.sortDirection) {
156
+ sp.set(k.sort, query.sortColumn);
157
+ if (query.sortDirection === 'desc')
158
+ sp.set(k.dir, 'desc');
159
+ }
160
+ if (query.groupByKey !== d.groupByKey)
161
+ sp.set(k.group, query.groupByKey ?? '');
162
+ for (const filter of query.activeFilters) {
163
+ sp.append(k.filter, `${encodeURIComponent(filter.column)}:${filter.operator}:${encodeURIComponent(filter.value)}`);
164
+ }
165
+ return sp;
166
+ }
167
+ /**
168
+ * Parse `URLSearchParams` back into a full table query, filling every missing
169
+ * param from the resolved defaults (see {@link TableQueryDefaults}).
170
+ *
171
+ * Read tolerant: non-numeric `page`/`size` fall back to the defaults, an
172
+ * unknown `dir` becomes `'asc'`, and malformed `filter` entries (wrong shape,
173
+ * unknown operator, broken percent-encoding) are skipped individually.
174
+ *
175
+ * Works anywhere a `URLSearchParams` exists — including `url.searchParams`
176
+ * in a server `load`, to run the initial server-mode fetch during SSR.
177
+ *
178
+ * @param params - Search params to read (not mutated).
179
+ * @param options - Fallback defaults + key prefix.
180
+ * @returns Complete query object (assignable to `TableQuery`).
181
+ */
182
+ export function searchParamsToTableQuery(params, options = {}) {
183
+ const d = resolveDefaults(options.defaults);
184
+ const k = paramKeys(options.prefix ?? '');
185
+ let sortColumn = d.sortColumn;
186
+ let sortDirection = d.sortDirection;
187
+ const rawSort = params.get(k.sort);
188
+ if (rawSort !== null) {
189
+ sortColumn = rawSort;
190
+ sortDirection = rawSort !== '' && params.get(k.dir) === 'desc' ? 'desc' : 'asc';
191
+ }
192
+ const rawGroup = params.get(k.group);
193
+ const activeFilters = [];
194
+ for (const raw of params.getAll(k.filter)) {
195
+ const filter = parseFilterParam(raw);
196
+ if (filter)
197
+ activeFilters.push(filter);
198
+ }
199
+ return {
200
+ page: parsePositiveInt(params.get(k.page)) ?? d.page,
201
+ itemsPerPage: parsePositiveInt(params.get(k.size)) ?? d.itemsPerPage,
202
+ sortColumn,
203
+ sortDirection,
204
+ searchTerm: params.get(k.q) ?? d.searchTerm,
205
+ activeFilters,
206
+ groupByKey: rawGroup !== null ? (rawGroup === '' ? null : rawGroup) : d.groupByKey
207
+ };
208
+ }
209
+ /**
210
+ * Merge a table query into existing search params: all managed keys (`q`,
211
+ * `page`, `size`, `sort`, `dir`, `group`, `filter` — with the configured
212
+ * prefix) are replaced by the serialized query, every other param is
213
+ * preserved untouched. Managed keys whose value returned to the default are
214
+ * removed (default elision).
215
+ *
216
+ * @param existing - Current search params (not mutated — a copy is returned).
217
+ * @param query - Query emitted by the table.
218
+ * @param options - Elision defaults + key prefix.
219
+ * @returns New `URLSearchParams` with the query applied.
220
+ * @throws TypeError when the query is structurally invalid (write strict).
221
+ */
222
+ export function applyTableQueryToSearchParams(existing, query, options = {}) {
223
+ const serialized = tableQueryToSearchParams(query, options);
224
+ const next = new URLSearchParams(existing);
225
+ for (const key of Object.values(paramKeys(options.prefix ?? ''))) {
226
+ next.delete(key);
227
+ }
228
+ for (const [key, value] of serialized) {
229
+ next.append(key, value);
230
+ }
231
+ return next;
232
+ }
@@ -1,21 +1,191 @@
1
+ import { type TableQueryParams, type TableQueryUrlOptions } from './table-query';
2
+ /**
3
+ * How {@link useUrlArrayParam} maps an array onto the URL:
4
+ * - `repeat` — one entry per key: `?tag=a&tag=b`
5
+ * - `csv` — a single delimited value: `?tag=a,b`
6
+ */
1
7
  export type UrlArrayStrategy = 'repeat' | 'csv';
8
+ /** Codec + seed for {@link useUrlParam} / {@link createUrlParam}. */
2
9
  export type UrlParamOptions<T> = {
10
+ /**
11
+ * Read the value out of the current search params. Return `null`/`undefined`
12
+ * to signal "absent" — the getter then yields {@link initial}.
13
+ */
3
14
  parse: (sp: URLSearchParams) => T | null | undefined;
15
+ /**
16
+ * Encode the value into `URLSearchParams`. The keys it produces are the ones
17
+ * the setter manages: on write they are cleared from the current URL and
18
+ * replaced by this output, leaving every other param untouched. Emit no
19
+ * entry for a key to remove it from the URL.
20
+ */
4
21
  serialize: (value: T) => URLSearchParams;
22
+ /** Value the getter returns when {@link parse} yields `null`/`undefined`. */
5
23
  initial: T;
24
+ /**
25
+ * Replace the current history entry instead of pushing a new one, so rapid
26
+ * filter/pagination edits don't flood the back button.
27
+ * @default true
28
+ */
6
29
  replaceState?: boolean;
7
30
  };
31
+ /**
32
+ * Low-level escape hatch to update several params at once via `goto` (without a
33
+ * full navigation). Starts from the current URL, applies `next`, and keeps
34
+ * every unrelated param.
35
+ *
36
+ * Merge semantics per key in `next`: the key is first cleared, then re-applied
37
+ * — a `URLSearchParams` re-appends all of its entries (repeated keys survive),
38
+ * a record `set`s a scalar, `append`s each array element, and **removes** the
39
+ * key entirely for a `null`/`undefined` value.
40
+ *
41
+ * @param next - Params to apply, as `URLSearchParams` or a plain record. A
42
+ * record value of `null`/`undefined` deletes that key.
43
+ * @param opts - `replaceState` (default `true`) — replace vs. push history.
44
+ * @example
45
+ * ```typescript
46
+ * updateUrlSearchParams({ page: '1', tag: ['a', 'b'], filter: null });
47
+ * // ?page=1&tag=a&tag=b (any prior `filter` param is dropped)
48
+ * ```
49
+ */
8
50
  export declare function updateUrlSearchParams(next: URLSearchParams | Record<string, string | string[]>, opts?: {
9
51
  replaceState?: boolean;
10
- keepPath?: boolean;
11
52
  }): void;
53
+ /**
54
+ * Non-reactive core of {@link useUrlParam}: builds the `get(sp)` / `set(value)`
55
+ * pair without touching the `page` rune, so `get` can be evaluated against any
56
+ * `URLSearchParams`. Prefer {@link useUrlParam} in components — this is the
57
+ * escape hatch when you need to read against a snapshot other than the live
58
+ * page URL (tests, a server `load`).
59
+ *
60
+ * `set` rewrites only the keys that `options.serialize` produces (clear +
61
+ * re-append) and preserves the rest, then navigates with `goto`
62
+ * (`replaceState`, `noScroll`, `keepFocus`).
63
+ *
64
+ * @param _key - Ignored — `options.parse`/`options.serialize` already close
65
+ * over the key (see {@link useUrlArrayParam}); kept only for signature parity
66
+ * with {@link useUrlParam}.
67
+ * @param options - Parse/serialize codec, initial value, history behaviour.
68
+ * @returns `{ get, set }` — `get(sp)` reads a value from the given params
69
+ * (falling back to `initial`), `set(value)` writes it to the URL.
70
+ */
12
71
  export declare function createUrlParam<T>(_key: string, options: UrlParamOptions<T>): {
13
72
  readonly get: (sp: URLSearchParams) => T;
14
73
  readonly set: (next: T) => void;
15
74
  };
75
+ /**
76
+ * Bind a typed value to a URL search param, reactively. The returned getter
77
+ * reads through the `page` rune, so it re-evaluates whenever the URL changes;
78
+ * the setter writes the value back via `goto` (no full navigation).
79
+ *
80
+ * SSR-safe: the getter only reads `page.url` (populated on the server), so the
81
+ * initial render reflects the incoming URL. The setter calls the client-only
82
+ * `goto` and is meant to run from event handlers/effects — never during SSR.
83
+ *
84
+ * A **getter**, not a store, is returned on purpose: call it lazily inside
85
+ * `$derived`/`$effect` and the read is tracked there.
86
+ *
87
+ * @typeParam T - The decoded value type.
88
+ * @param key - Param key (forwarded to `createUrlParam` for signature parity;
89
+ * the actual key handling lives in `options.parse`/`options.serialize`).
90
+ * @param options - Parse/serialize codec, initial value, history behaviour.
91
+ * @returns `[get, set]` — `get()` reads the live value, `set(value)` writes it.
92
+ * @example
93
+ * ```svelte
94
+ * <script lang="ts">
95
+ * import { useUrlParam } from '@urbicon-ui/sveltekit-utils/url.svelte';
96
+ *
97
+ * const [page, setPage] = useUrlParam<number>('page', {
98
+ * parse: (sp) => Number(sp.get('page') ?? '1'),
99
+ * serialize: (v) => new URLSearchParams({ page: String(v) }),
100
+ * initial: 1
101
+ * });
102
+ * </script>
103
+ *
104
+ * <button onclick={() => setPage(page() + 1)}>Next — {page()}</button>
105
+ * ```
106
+ */
16
107
  export declare function useUrlParam<T>(key: string, options: UrlParamOptions<T>): readonly [() => T, (next: T) => void];
108
+ /**
109
+ * {@link useUrlParam} specialised for a `string[]`, with the encoding handled
110
+ * for you. Reactive read + `goto`-based write, same as {@link useUrlParam}.
111
+ *
112
+ * The `csv` strategy drops empty segments on read (`?tag=` → `[]`) and writes
113
+ * no param for an empty array, so an empty selection leaves the URL clean.
114
+ *
115
+ * @param key - The param key.
116
+ * @param opts - `initial` seed, `strategy` (default `'repeat'`), and
117
+ * `delimiter` for `csv` (default `','`). See {@link UrlArrayStrategy}.
118
+ * @returns `[get, set]` — `get()` reads the current `string[]`, `set(values)`
119
+ * writes it.
120
+ * @example
121
+ * ```typescript
122
+ * const [tags, setTags] = useUrlArrayParam('tag', { initial: [] }); // ?tag=a&tag=b
123
+ * const [cats, setCats] = useUrlArrayParam('cat', { initial: [], strategy: 'csv' }); // ?cat=a,b
124
+ * ```
125
+ */
17
126
  export declare function useUrlArrayParam(key: string, opts: {
18
127
  initial: string[];
19
128
  strategy?: UrlArrayStrategy;
20
129
  delimiter?: string;
21
130
  }): readonly [() => string[], (next: string[]) => void];
131
+ /** Options for {@link createTableQueryUrlSync}. */
132
+ export interface TableQueryUrlSyncOptions extends TableQueryUrlOptions {
133
+ /**
134
+ * Replace the current history entry instead of pushing a new one. The
135
+ * default (`true`) keeps every sort/filter/page interaction from polluting
136
+ * the back button.
137
+ * @default true
138
+ */
139
+ replaceState?: boolean;
140
+ }
141
+ /**
142
+ * Opt-in URL sync for a table in server mode: mirrors the `TableQuery` the
143
+ * table emits onto `?q=…&sort=…&page=…` query params, so the view state
144
+ * survives reloads and can be shared as a link.
145
+ *
146
+ * Two directions, both explicit:
147
+ * - **URL → query**: `initialQuery` is parsed once at creation (SSR-safe) —
148
+ * seed the table (`initialPage`, `initialGroupBy`, controlled `searchTerm`)
149
+ * and run the first fetch from it.
150
+ * - **query → URL**: pass `syncQuery` the query from `onQueryChange`, or call
151
+ * it inside `queryFn` (when `queryFn` is set, `onQueryChange` does not
152
+ * fire). It rewrites only its own — optionally prefixed — params via
153
+ * `goto` (`replaceState`, `noScroll`, `keepFocus`); unrelated params are
154
+ * preserved. Values equal to `options.defaults` are elided, so a table in
155
+ * its default state leaves the URL clean.
156
+ *
157
+ * Set `options.defaults` to the table's initial props (`itemsPerPage`,
158
+ * `initialPage`, `initialGroupBy`) so the elision baseline matches the state
159
+ * the table actually starts in.
160
+ *
161
+ * @example
162
+ * ```svelte
163
+ * <script lang="ts">
164
+ * import { Table } from '@urbicon-ui/table';
165
+ * import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
166
+ *
167
+ * const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
168
+ * </script>
169
+ *
170
+ * <Table
171
+ * mode="server"
172
+ * columns={columns}
173
+ * itemsPerPage={25}
174
+ * initialPage={sync.initialQuery.page}
175
+ * queryFn={async (query, { signal }) => {
176
+ * sync.syncQuery(query);
177
+ * const res = await fetch(`/api/users?${new URLSearchParams(...)}`, { signal });
178
+ * const data = await res.json();
179
+ * return { items: data.results, totalItems: data.total };
180
+ * }}
181
+ * />
182
+ * ```
183
+ *
184
+ * @param options - Elision defaults, key prefix, history behaviour.
185
+ * @returns `initialQuery` (the URL parsed at creation time) + `syncQuery`
186
+ * (write a query back to the URL).
187
+ */
188
+ export declare function createTableQueryUrlSync(options?: TableQueryUrlSyncOptions): {
189
+ readonly initialQuery: TableQueryParams;
190
+ readonly syncQuery: (query: TableQueryParams) => void;
191
+ };
@@ -1,5 +1,25 @@
1
1
  import { goto } from '$app/navigation';
2
2
  import { page } from '$app/state';
3
+ import { applyTableQueryToSearchParams, searchParamsToTableQuery } from './table-query';
4
+ /**
5
+ * Low-level escape hatch to update several params at once via `goto` (without a
6
+ * full navigation). Starts from the current URL, applies `next`, and keeps
7
+ * every unrelated param.
8
+ *
9
+ * Merge semantics per key in `next`: the key is first cleared, then re-applied
10
+ * — a `URLSearchParams` re-appends all of its entries (repeated keys survive),
11
+ * a record `set`s a scalar, `append`s each array element, and **removes** the
12
+ * key entirely for a `null`/`undefined` value.
13
+ *
14
+ * @param next - Params to apply, as `URLSearchParams` or a plain record. A
15
+ * record value of `null`/`undefined` deletes that key.
16
+ * @param opts - `replaceState` (default `true`) — replace vs. push history.
17
+ * @example
18
+ * ```typescript
19
+ * updateUrlSearchParams({ page: '1', tag: ['a', 'b'], filter: null });
20
+ * // ?page=1&tag=a&tag=b (any prior `filter` param is dropped)
21
+ * ```
22
+ */
3
23
  // Local imperative use of URLSearchParams — not reactive state — so the
4
24
  // SvelteURLSearchParams wrapper is unnecessary here. Likewise for `goto`:
5
25
  // we pass constructed relative paths, not resolved route ids; callers of
@@ -25,13 +45,31 @@ export function updateUrlSearchParams(next, opts) {
25
45
  }
26
46
  }
27
47
  const q = base.toString();
28
- const path = opts?.keepPath ? page.url.pathname : '/';
48
+ const path = page.url.pathname;
29
49
  goto(q ? `${path}?${q}` : path, {
30
50
  replaceState: opts?.replaceState ?? true,
31
51
  noScroll: true,
32
52
  keepFocus: true
33
53
  });
34
54
  }
55
+ /**
56
+ * Non-reactive core of {@link useUrlParam}: builds the `get(sp)` / `set(value)`
57
+ * pair without touching the `page` rune, so `get` can be evaluated against any
58
+ * `URLSearchParams`. Prefer {@link useUrlParam} in components — this is the
59
+ * escape hatch when you need to read against a snapshot other than the live
60
+ * page URL (tests, a server `load`).
61
+ *
62
+ * `set` rewrites only the keys that `options.serialize` produces (clear +
63
+ * re-append) and preserves the rest, then navigates with `goto`
64
+ * (`replaceState`, `noScroll`, `keepFocus`).
65
+ *
66
+ * @param _key - Ignored — `options.parse`/`options.serialize` already close
67
+ * over the key (see {@link useUrlArrayParam}); kept only for signature parity
68
+ * with {@link useUrlParam}.
69
+ * @param options - Parse/serialize codec, initial value, history behaviour.
70
+ * @returns `{ get, set }` — `get(sp)` reads a value from the given params
71
+ * (falling back to `initial`), `set(value)` writes it to the URL.
72
+ */
35
73
  // `key` is unused here — `options.parse`/`options.serialize` already close over
36
74
  // it (see useUrlArrayParam) — but kept for signature parity with useUrlParam.
37
75
  export function createUrlParam(_key, options) {
@@ -44,7 +82,7 @@ export function createUrlParam(_key, options) {
44
82
  for (const [k, v] of nextSp)
45
83
  current.append(k, v);
46
84
  const q = current.toString();
47
- goto(q ? `?${q}` : '/', {
85
+ goto(q ? `?${q}` : page.url.pathname, {
48
86
  replaceState: options.replaceState ?? true,
49
87
  noScroll: true,
50
88
  keepFocus: true
@@ -52,11 +90,61 @@ export function createUrlParam(_key, options) {
52
90
  }
53
91
  return { get, set: setValue };
54
92
  }
93
+ /**
94
+ * Bind a typed value to a URL search param, reactively. The returned getter
95
+ * reads through the `page` rune, so it re-evaluates whenever the URL changes;
96
+ * the setter writes the value back via `goto` (no full navigation).
97
+ *
98
+ * SSR-safe: the getter only reads `page.url` (populated on the server), so the
99
+ * initial render reflects the incoming URL. The setter calls the client-only
100
+ * `goto` and is meant to run from event handlers/effects — never during SSR.
101
+ *
102
+ * A **getter**, not a store, is returned on purpose: call it lazily inside
103
+ * `$derived`/`$effect` and the read is tracked there.
104
+ *
105
+ * @typeParam T - The decoded value type.
106
+ * @param key - Param key (forwarded to `createUrlParam` for signature parity;
107
+ * the actual key handling lives in `options.parse`/`options.serialize`).
108
+ * @param options - Parse/serialize codec, initial value, history behaviour.
109
+ * @returns `[get, set]` — `get()` reads the live value, `set(value)` writes it.
110
+ * @example
111
+ * ```svelte
112
+ * <script lang="ts">
113
+ * import { useUrlParam } from '@urbicon-ui/sveltekit-utils/url.svelte';
114
+ *
115
+ * const [page, setPage] = useUrlParam<number>('page', {
116
+ * parse: (sp) => Number(sp.get('page') ?? '1'),
117
+ * serialize: (v) => new URLSearchParams({ page: String(v) }),
118
+ * initial: 1
119
+ * });
120
+ * </script>
121
+ *
122
+ * <button onclick={() => setPage(page() + 1)}>Next — {page()}</button>
123
+ * ```
124
+ */
55
125
  export function useUrlParam(key, options) {
56
126
  const { get, set } = createUrlParam(key, options);
57
127
  const getBound = () => get(page.url.searchParams);
58
128
  return [getBound, set];
59
129
  }
130
+ /**
131
+ * {@link useUrlParam} specialised for a `string[]`, with the encoding handled
132
+ * for you. Reactive read + `goto`-based write, same as {@link useUrlParam}.
133
+ *
134
+ * The `csv` strategy drops empty segments on read (`?tag=` → `[]`) and writes
135
+ * no param for an empty array, so an empty selection leaves the URL clean.
136
+ *
137
+ * @param key - The param key.
138
+ * @param opts - `initial` seed, `strategy` (default `'repeat'`), and
139
+ * `delimiter` for `csv` (default `','`). See {@link UrlArrayStrategy}.
140
+ * @returns `[get, set]` — `get()` reads the current `string[]`, `set(values)`
141
+ * writes it.
142
+ * @example
143
+ * ```typescript
144
+ * const [tags, setTags] = useUrlArrayParam('tag', { initial: [] }); // ?tag=a&tag=b
145
+ * const [cats, setCats] = useUrlArrayParam('cat', { initial: [], strategy: 'csv' }); // ?cat=a,b
146
+ * ```
147
+ */
60
148
  export function useUrlArrayParam(key, opts) {
61
149
  const strategy = opts.strategy ?? 'repeat';
62
150
  const delimiter = opts.delimiter ?? ',';
@@ -80,3 +168,63 @@ export function useUrlArrayParam(key, opts) {
80
168
  };
81
169
  return useUrlParam(key, { parse, serialize, initial: opts.initial });
82
170
  }
171
+ /**
172
+ * Opt-in URL sync for a table in server mode: mirrors the `TableQuery` the
173
+ * table emits onto `?q=…&sort=…&page=…` query params, so the view state
174
+ * survives reloads and can be shared as a link.
175
+ *
176
+ * Two directions, both explicit:
177
+ * - **URL → query**: `initialQuery` is parsed once at creation (SSR-safe) —
178
+ * seed the table (`initialPage`, `initialGroupBy`, controlled `searchTerm`)
179
+ * and run the first fetch from it.
180
+ * - **query → URL**: pass `syncQuery` the query from `onQueryChange`, or call
181
+ * it inside `queryFn` (when `queryFn` is set, `onQueryChange` does not
182
+ * fire). It rewrites only its own — optionally prefixed — params via
183
+ * `goto` (`replaceState`, `noScroll`, `keepFocus`); unrelated params are
184
+ * preserved. Values equal to `options.defaults` are elided, so a table in
185
+ * its default state leaves the URL clean.
186
+ *
187
+ * Set `options.defaults` to the table's initial props (`itemsPerPage`,
188
+ * `initialPage`, `initialGroupBy`) so the elision baseline matches the state
189
+ * the table actually starts in.
190
+ *
191
+ * @example
192
+ * ```svelte
193
+ * <script lang="ts">
194
+ * import { Table } from '@urbicon-ui/table';
195
+ * import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
196
+ *
197
+ * const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
198
+ * </script>
199
+ *
200
+ * <Table
201
+ * mode="server"
202
+ * columns={columns}
203
+ * itemsPerPage={25}
204
+ * initialPage={sync.initialQuery.page}
205
+ * queryFn={async (query, { signal }) => {
206
+ * sync.syncQuery(query);
207
+ * const res = await fetch(`/api/users?${new URLSearchParams(...)}`, { signal });
208
+ * const data = await res.json();
209
+ * return { items: data.results, totalItems: data.total };
210
+ * }}
211
+ * />
212
+ * ```
213
+ *
214
+ * @param options - Elision defaults, key prefix, history behaviour.
215
+ * @returns `initialQuery` (the URL parsed at creation time) + `syncQuery`
216
+ * (write a query back to the URL).
217
+ */
218
+ export function createTableQueryUrlSync(options = {}) {
219
+ const initialQuery = searchParamsToTableQuery(page.url.searchParams, options);
220
+ function syncQuery(query) {
221
+ const next = applyTableQueryToSearchParams(page.url.searchParams, query, options);
222
+ const qs = next.toString();
223
+ goto(`${page.url.pathname}${qs ? `?${qs}` : ''}${page.url.hash}`, {
224
+ replaceState: options.replaceState ?? true,
225
+ noScroll: true,
226
+ keepFocus: true
227
+ });
228
+ }
229
+ return { initialQuery, syncQuery };
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/sveltekit-utils",
3
- "version": "6.21.3",
3
+ "version": "6.23.0",
4
4
  "description": "SvelteKit helper utilities — createCronRunner and URL-state runes",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,10 +40,17 @@
40
40
  "./cron": {
41
41
  "types": "./dist/cron.d.ts",
42
42
  "import": "./dist/cron.js"
43
+ },
44
+ "./table-query": {
45
+ "types": "./dist/table-query.d.ts",
46
+ "import": "./dist/table-query.js",
47
+ "default": "./dist/table-query.js"
43
48
  }
44
49
  },
45
50
  "files": [
46
- "dist"
51
+ "dist",
52
+ "!dist/**/*.test.*",
53
+ "!dist/**/*.spec.*"
47
54
  ],
48
55
  "scripts": {
49
56
  "dev": "svelte-package --watch",
@@ -1 +0,0 @@
1
- export {};
package/dist/cron.test.js DELETED
@@ -1,137 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { createCronRunner } from './cron';
3
- describe('createCronRunner', () => {
4
- beforeEach(() => {
5
- vi.useFakeTimers();
6
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })));
7
- });
8
- afterEach(() => {
9
- vi.useRealTimers();
10
- vi.restoreAllMocks();
11
- });
12
- it('should not be running initially', () => {
13
- const runner = createCronRunner({
14
- secret: 'test-secret',
15
- jobs: [{ path: '/api/test', intervalSeconds: 60 }]
16
- });
17
- expect(runner.isRunning()).toBe(false);
18
- });
19
- it('should be running after start()', () => {
20
- const runner = createCronRunner({
21
- secret: 'test-secret',
22
- jobs: [{ path: '/api/test', intervalSeconds: 60 }]
23
- });
24
- runner.start();
25
- expect(runner.isRunning()).toBe(true);
26
- runner.stop();
27
- });
28
- it('should not be running after stop()', () => {
29
- const runner = createCronRunner({
30
- secret: 'test-secret',
31
- jobs: [{ path: '/api/test', intervalSeconds: 60 }]
32
- });
33
- runner.start();
34
- runner.stop();
35
- expect(runner.isRunning()).toBe(false);
36
- });
37
- it('should not start twice', () => {
38
- const runner = createCronRunner({
39
- secret: 'test-secret',
40
- jobs: [{ path: '/api/test', intervalSeconds: 10 }]
41
- });
42
- runner.start();
43
- runner.start();
44
- vi.advanceTimersByTime(10_000);
45
- expect(fetch).toHaveBeenCalledTimes(1);
46
- runner.stop();
47
- });
48
- it('should call fetch for each job at the correct interval', async () => {
49
- const runner = createCronRunner({
50
- secret: 'my-secret',
51
- baseUrl: 'http://localhost:5000',
52
- jobs: [
53
- { path: '/api/job-a', intervalSeconds: 10 },
54
- { path: '/api/job-b', intervalSeconds: 20, method: 'GET' }
55
- ]
56
- });
57
- runner.start();
58
- // At 10s: job-a fires
59
- await vi.advanceTimersByTimeAsync(10_000);
60
- expect(fetch).toHaveBeenCalledTimes(1);
61
- expect(fetch).toHaveBeenCalledWith('http://localhost:5000/api/job-a', {
62
- method: 'POST',
63
- headers: { 'x-cron-secret': 'my-secret' }
64
- });
65
- // At 20s: job-a fires again + job-b fires for the first time
66
- await vi.advanceTimersByTimeAsync(10_000);
67
- expect(fetch).toHaveBeenCalledTimes(3);
68
- expect(fetch).toHaveBeenCalledWith('http://localhost:5000/api/job-b', {
69
- method: 'GET',
70
- headers: { 'x-cron-secret': 'my-secret' }
71
- });
72
- runner.stop();
73
- });
74
- it('should use default baseUrl when not provided', async () => {
75
- const runner = createCronRunner({
76
- secret: 's',
77
- jobs: [{ path: '/api/ping', intervalSeconds: 5 }]
78
- });
79
- runner.start();
80
- await vi.advanceTimersByTimeAsync(5_000);
81
- expect(fetch).toHaveBeenCalledWith('http://localhost:3000/api/ping', expect.any(Object));
82
- runner.stop();
83
- });
84
- it('should use custom secretHeader', async () => {
85
- const runner = createCronRunner({
86
- secret: 'abc',
87
- secretHeader: 'x-internal-key',
88
- jobs: [{ path: '/api/test', intervalSeconds: 5 }]
89
- });
90
- runner.start();
91
- await vi.advanceTimersByTimeAsync(5_000);
92
- expect(fetch).toHaveBeenCalledWith(expect.any(String), {
93
- method: 'POST',
94
- headers: { 'x-internal-key': 'abc' }
95
- });
96
- runner.stop();
97
- });
98
- it('should call onError when fetch throws', async () => {
99
- const error = new Error('Network error');
100
- vi.mocked(fetch).mockRejectedValueOnce(error);
101
- const onError = vi.fn();
102
- const job = { path: '/api/fail', intervalSeconds: 5 };
103
- const runner = createCronRunner({
104
- secret: 's',
105
- jobs: [job],
106
- onError
107
- });
108
- runner.start();
109
- await vi.advanceTimersByTimeAsync(5_000);
110
- expect(onError).toHaveBeenCalledWith(job, error);
111
- runner.stop();
112
- });
113
- it('should not call onError when fetch succeeds', async () => {
114
- const onError = vi.fn();
115
- const runner = createCronRunner({
116
- secret: 's',
117
- jobs: [{ path: '/api/ok', intervalSeconds: 5 }],
118
- onError
119
- });
120
- runner.start();
121
- await vi.advanceTimersByTimeAsync(5_000);
122
- expect(onError).not.toHaveBeenCalled();
123
- runner.stop();
124
- });
125
- it('should stop all timers and not fire after stop()', async () => {
126
- const runner = createCronRunner({
127
- secret: 's',
128
- jobs: [{ path: '/api/test', intervalSeconds: 5 }]
129
- });
130
- runner.start();
131
- await vi.advanceTimersByTimeAsync(5_000);
132
- expect(fetch).toHaveBeenCalledTimes(1);
133
- runner.stop();
134
- await vi.advanceTimersByTimeAsync(15_000);
135
- expect(fetch).toHaveBeenCalledTimes(1);
136
- });
137
- });