firstly 0.11.0 → 0.12.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/CHANGELOG.md +16 -0
- package/esm/changeLog/index.d.ts +1 -0
- package/esm/changeLog/server/index.d.ts +2 -2
- package/esm/changeLog/server/index.js +2 -2
- package/esm/core/FF_Allow.d.ts +3 -3
- package/esm/core/FF_Allow.js +3 -3
- package/esm/core/FF_Filter.d.ts +3 -3
- package/esm/core/FF_Filter.js +3 -3
- package/esm/core/FF_Validators.d.ts +2 -2
- package/esm/core/FF_Validators.js +2 -2
- package/esm/core/index.d.ts +1 -0
- package/esm/core/index.js +1 -1
- package/esm/cron/server/index.d.ts +1 -1
- package/esm/cron/server/index.js +1 -1
- package/esm/mail/ui/WriteMail.svelte +1 -1
- package/esm/sqlAdmin/server/index.d.ts +2 -2
- package/esm/sqlAdmin/server/index.js +2 -2
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +1 -2
- package/esm/sqlAdmin/ui/SqlDrift.svelte +41 -41
- package/esm/svelte/FF_DialogManager.svelte +4 -4
- package/esm/svelte/FF_PromptDefault.svelte +6 -6
- package/esm/svelte/class/SP.svelte.d.ts +1 -0
- package/esm/svelte/class/SP.svelte.js +14 -15
- package/esm/svelte/dialog.svelte.d.ts +1 -1
- package/esm/svelte/grid/FF_Grid.svelte +11 -8
- package/esm/svelte/grid/buildCells.js +6 -0
- package/esm/svelte/grid/cellTypes.d.ts +4 -0
- package/esm/svelte/handleError.d.ts +8 -2
- package/esm/svelte/handleError.js +24 -2
- package/esm/svelte/index.d.ts +1 -1
- package/esm/svelte/index.js +1 -1
- package/esm/svelte/query/index.svelte.d.ts +40 -0
- package/esm/svelte/query/index.svelte.js +112 -0
- package/esm/svelte/query/read.remote.d.ts +7 -0
- package/esm/svelte/query/read.remote.js +27 -0
- package/esm/svelte/remultApiLoad.d.ts +21 -9
- package/esm/svelte/remultApiLoad.js +21 -9
- package/esm/svelte/server/handleCaching.d.ts +2 -2
- package/esm/svelte/server/handleCaching.js +1 -1
- package/esm/svelte/ui/Icon.svelte +0 -1
- package/esm/vite/index.js +11 -1
- package/package.json +8 -3
- package/bin/args.d.ts +0 -13
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
// FIXME
|
|
2
|
-
// Why packages/ui pnpm check fails with goto and page?
|
|
3
|
-
// - OK it's not a sveltekit project... But this is never used! So why is it checking it ?!
|
|
4
|
-
// @ts-ignore
|
|
5
1
|
import { goto } from '$app/navigation';
|
|
6
|
-
// @ts-ignore
|
|
7
2
|
import { page } from '$app/state';
|
|
8
3
|
import { debounce } from '../helpers/debounce.js';
|
|
9
4
|
const CONFIG_DELIMITER = ';';
|
|
@@ -23,6 +18,7 @@ export class SP {
|
|
|
23
18
|
computing = $state(false);
|
|
24
19
|
// Store debounced toURL functions for each parameter
|
|
25
20
|
debouncedToURL = {};
|
|
21
|
+
lastNavigation;
|
|
26
22
|
// Shared debounce mechanism for all parameters
|
|
27
23
|
sharedDebouncePending = $state(false);
|
|
28
24
|
activeDebounces = $state({});
|
|
@@ -37,9 +33,8 @@ export class SP {
|
|
|
37
33
|
for (const key of Object.keys(this.paramValues)) {
|
|
38
34
|
this.debouncedValues[key] = this.paramValues[key];
|
|
39
35
|
}
|
|
40
|
-
//
|
|
36
|
+
// `computing` stays on until toURL's navigation lands
|
|
41
37
|
this.sharedDebouncePending = false;
|
|
42
|
-
this.computing = false;
|
|
43
38
|
this.longestDebounceTime = 0;
|
|
44
39
|
this.toURL();
|
|
45
40
|
};
|
|
@@ -393,16 +388,21 @@ export class SP {
|
|
|
393
388
|
}
|
|
394
389
|
const strSearch = params.toString() ? '?' + params.toString() : '';
|
|
395
390
|
// Don't do the goto if the search params haven't changed!
|
|
396
|
-
if (strSearch === window.location.search)
|
|
391
|
+
if (strSearch === window.location.search) {
|
|
392
|
+
this.computing = false;
|
|
397
393
|
return;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
394
|
+
}
|
|
395
|
+
const navigation = goto(`${window.location.pathname}${strSearch}`, {
|
|
396
|
+
replace: true,
|
|
397
|
+
reset: false,
|
|
402
398
|
...this.options.gotoOpts,
|
|
403
399
|
});
|
|
404
|
-
|
|
405
|
-
|
|
400
|
+
this.lastNavigation = navigation;
|
|
401
|
+
// A newer navigation or a pending debounce keeps `computing` on
|
|
402
|
+
void navigation.finally(() => {
|
|
403
|
+
if (this.lastNavigation === navigation && !this.sharedDebouncePending)
|
|
404
|
+
this.computing = false;
|
|
405
|
+
});
|
|
406
406
|
}
|
|
407
407
|
/**
|
|
408
408
|
* Reset all parameters to their default values
|
|
@@ -418,7 +418,6 @@ export class SP {
|
|
|
418
418
|
this.sharedDebouncePending = false;
|
|
419
419
|
this.longestDebounceTime = 0;
|
|
420
420
|
// Update URL immediately without debounce
|
|
421
|
-
this.computing = false;
|
|
422
421
|
this.toURL();
|
|
423
422
|
}
|
|
424
423
|
}
|
|
@@ -15,7 +15,7 @@ export { resolveMessage } from '../core/FF_Validators.js';
|
|
|
15
15
|
* cancelled/dismissed - no per-method `boolean` / `null` special-casing.
|
|
16
16
|
*
|
|
17
17
|
* ```svelte
|
|
18
|
-
* import { dialog } from '
|
|
18
|
+
* import { dialog } from 'firstly/svelte'
|
|
19
19
|
*
|
|
20
20
|
* {#snippet body(close)}
|
|
21
21
|
* <input bind:value={name} />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends object">
|
|
2
|
-
// PUBLISHED batteries-included grid — `import { FF_Grid } from '
|
|
2
|
+
// PUBLISHED batteries-included grid — `import { FF_Grid } from 'firstly/svelte'` and go, zero setup:
|
|
3
3
|
// it bundles a default input + a neutral default skin. Reads the entity `hub` (every prop overrides).
|
|
4
4
|
// For full control over markup + look, copy the boutique <App_Grid> instead (degit src/boutique/grid).
|
|
5
5
|
import { untrack } from 'svelte'
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import { errorMessage } from '../../core/helper.js'
|
|
11
11
|
import type { DialogClose } from '../dialog.svelte.js'
|
|
12
|
-
import FF_Config from '../FF_Config.svelte'
|
|
13
|
-
import { ffConfig } from '../FF_Config.svelte.js'
|
|
14
12
|
import { ff } from '../ff.svelte.js'
|
|
15
13
|
import type { FF_Many, ManyStrategy } from '../ff.svelte.js'
|
|
14
|
+
import FF_Config from '../FF_Config.svelte'
|
|
15
|
+
import { ffConfig } from '../FF_Config.svelte.js'
|
|
16
16
|
import Icon from '../ui/Icon.svelte'
|
|
17
17
|
import { LibIcon_Add, LibIcon_Edit } from '../ui/LibIcon.js'
|
|
18
18
|
import { buildCells } from './buildCells.js'
|
|
@@ -61,7 +61,10 @@
|
|
|
61
61
|
const strategy = untrack(() => strategyProp ?? hub.strategy ?? 'paginate')
|
|
62
62
|
const pageSize = untrack(() => pageSizeProp ?? hub.pageSize ?? 25)
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// fall back to the entity default so the header arrow shows what remult applies on landing
|
|
65
|
+
let sort = $state<EntityOrderBy<T> | undefined>(
|
|
66
|
+
untrack(() => orderBy ?? hub.orderBy ?? repo(entity).metadata.options.defaultOrderBy),
|
|
67
|
+
)
|
|
65
68
|
|
|
66
69
|
const m = untrack(() =>
|
|
67
70
|
ff(entity).many(
|
|
@@ -101,9 +104,9 @@
|
|
|
101
104
|
const newIcon = $derived(insertCfg !== false ? (insertCfg.icon ?? LibIcon_Add) : LibIcon_Add)
|
|
102
105
|
const editIcon = $derived(updateCfg !== false ? (updateCfg.icon ?? LibIcon_Edit) : LibIcon_Edit)
|
|
103
106
|
|
|
104
|
-
function toggleSort(key: string) {
|
|
107
|
+
function toggleSort(key: string, first: 'asc' | 'desc') {
|
|
105
108
|
const cur = (sort as Record<string, 'asc' | 'desc'> | undefined)?.[key]
|
|
106
|
-
sort = { [key]: cur === 'asc' ? 'desc' : 'asc' } as EntityOrderBy<T>
|
|
109
|
+
sort = { [key]: cur ? (cur === 'asc' ? 'desc' : 'asc') : first } as EntityOrderBy<T>
|
|
107
110
|
}
|
|
108
111
|
const sortDir = (key: string) => (sort as Record<string, string> | undefined)?.[key]
|
|
109
112
|
|
|
@@ -185,9 +188,9 @@
|
|
|
185
188
|
data-sortable={cell.sortable || undefined}
|
|
186
189
|
style:text-align={cell.align}
|
|
187
190
|
class={cell.class}
|
|
188
|
-
onclick={() => cell.sortable && cell.col && toggleSort(cell.col)}
|
|
191
|
+
onclick={() => cell.sortable && cell.col && toggleSort(cell.col, cell.firstSort)}
|
|
189
192
|
>
|
|
190
|
-
{cell.caption}{#if cell.col && sortDir(cell.col)}<span data-sort
|
|
193
|
+
{cell.caption}{#if cell.sortable && cell.col && sortDir(cell.col)}<span data-sort
|
|
191
194
|
>{sortDir(cell.col) === 'asc' ? ' ▲' : ' ▼'}</span
|
|
192
195
|
>{/if}
|
|
193
196
|
</th>
|
|
@@ -15,6 +15,11 @@ function alignFor(field, ui) {
|
|
|
15
15
|
return 'right';
|
|
16
16
|
return 'left';
|
|
17
17
|
}
|
|
18
|
+
// newest / biggest first is the useful first view for dates + numbers
|
|
19
|
+
function firstSortFor(field) {
|
|
20
|
+
const t = field?.valueType;
|
|
21
|
+
return t === Date || t === Number ? 'desc' : 'asc';
|
|
22
|
+
}
|
|
18
23
|
/** Resolve the render kind: explicit > href(field_link) > metaKind(relation/enum) > field. */
|
|
19
24
|
function resolveKind(field, explicit) {
|
|
20
25
|
if (explicit)
|
|
@@ -58,6 +63,7 @@ export function buildCells(meta, cells, opts) {
|
|
|
58
63
|
inputType: field ? getInputType(field, ui) : 'text',
|
|
59
64
|
align: alignFor(field, ui),
|
|
60
65
|
sortable,
|
|
66
|
+
firstSort: (isObj && item.firstSort) || firstSortFor(field),
|
|
61
67
|
class: isObj ? item.class : undefined,
|
|
62
68
|
cellSnippet: isObj ? item.cellSnippet : undefined,
|
|
63
69
|
component: isObj ? item.component : undefined,
|
|
@@ -41,6 +41,8 @@ export interface Cell<E = any> {
|
|
|
41
41
|
align: 'left' | 'center' | 'right';
|
|
42
42
|
/** Whether this column is sortable by header click (resolved; opt in via `sortable: true`). */
|
|
43
43
|
sortable: boolean;
|
|
44
|
+
/** Direction of the first header click on an unsorted column. */
|
|
45
|
+
firstSort: 'asc' | 'desc';
|
|
44
46
|
/** Tailwind/CSS passthrough (e.g. 'col-span-2'). */
|
|
45
47
|
class?: string;
|
|
46
48
|
cellSnippet?: Snippet<[{
|
|
@@ -62,6 +64,8 @@ export type CellInput<E> = (keyof E & string) | {
|
|
|
62
64
|
class?: string;
|
|
63
65
|
/** Set true to make this column sortable by header click (default: not sortable). */
|
|
64
66
|
sortable?: boolean;
|
|
67
|
+
/** First header click direction. Default: `desc` for Date/Number fields, else `asc`. */
|
|
68
|
+
firstSort?: 'asc' | 'desc';
|
|
65
69
|
cellSnippet?: Snippet<[{
|
|
66
70
|
row: E;
|
|
67
71
|
cell: Cell<E>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HandleClientError } from '@sveltejs/kit';
|
|
1
|
+
import type { HandleClientError } from '@sveltejs/kit/hooks';
|
|
2
2
|
export type HandleClientErrorMiddleware = (next: HandleClientError) => HandleClientError;
|
|
3
3
|
/**
|
|
4
4
|
* Compose `handleError` middlewares into a single `hooks.client.js` handler.
|
|
@@ -8,7 +8,7 @@ export type HandleClientErrorMiddleware = (next: HandleClientError) => HandleCli
|
|
|
8
8
|
*
|
|
9
9
|
* ```js
|
|
10
10
|
* // src/hooks.client.js
|
|
11
|
-
* import { stackHandleClientError, withStaleDeployReload } from '
|
|
11
|
+
* import { stackHandleClientError, withStaleDeployReload } from 'firstly/svelte'
|
|
12
12
|
*
|
|
13
13
|
* export const handleError = stackHandleClientError(
|
|
14
14
|
* withStaleDeployReload(),
|
|
@@ -29,3 +29,9 @@ export declare function stackHandleClientError(...middlewares: HandleClientError
|
|
|
29
29
|
* falls through to `next`.
|
|
30
30
|
*/
|
|
31
31
|
export declare function withStaleDeployReload(): HandleClientErrorMiddleware;
|
|
32
|
+
/**
|
|
33
|
+
* Recovers what Kit leaves out after a deploy: a lazy `import()` inside a component. Reloads
|
|
34
|
+
* only when `updated.check()` confirms a new version, so a flaky network can't loop. Call it
|
|
35
|
+
* at the top of `hooks.client.js`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function reloadOnStaleChunk(): void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { navigating, updated } from '$app/state';
|
|
1
2
|
/**
|
|
2
3
|
* Compose `handleError` middlewares into a single `hooks.client.js` handler.
|
|
3
4
|
* Middlewares run outermost-first (the first argument wraps the rest). A middleware
|
|
@@ -6,7 +7,7 @@
|
|
|
6
7
|
*
|
|
7
8
|
* ```js
|
|
8
9
|
* // src/hooks.client.js
|
|
9
|
-
* import { stackHandleClientError, withStaleDeployReload } from '
|
|
10
|
+
* import { stackHandleClientError, withStaleDeployReload } from 'firstly/svelte'
|
|
10
11
|
*
|
|
11
12
|
* export const handleError = stackHandleClientError(
|
|
12
13
|
* withStaleDeployReload(),
|
|
@@ -22,6 +23,8 @@ export function stackHandleClientError(...middlewares) {
|
|
|
22
23
|
}
|
|
23
24
|
// Errors SvelteKit/Vite throw when a lazy chunk 404s after a deploy.
|
|
24
25
|
const CHUNK_LOAD_RE = /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/;
|
|
26
|
+
// Kit recovers route chunks itself, and one failing on hover must not reload the page.
|
|
27
|
+
const ROUTE_CHUNK_RE = /\/immutable\/nodes\//;
|
|
25
28
|
const STORAGE_KEY = 'ff-reload';
|
|
26
29
|
// A URL that failed again within this window is a genuine break, not a stale deploy.
|
|
27
30
|
const RETRY_WINDOW_MS = 10_000;
|
|
@@ -35,12 +38,19 @@ const RETRY_WINDOW_MS = 10_000;
|
|
|
35
38
|
*/
|
|
36
39
|
export function withStaleDeployReload() {
|
|
37
40
|
return (next) => (input) => {
|
|
38
|
-
if (
|
|
41
|
+
if (input.kind === 'unknown' &&
|
|
42
|
+
CHUNK_LOAD_RE.test(String(input.error)) &&
|
|
43
|
+
isVisited(input.event.url) &&
|
|
44
|
+
reloadOnce(input.event.url.href)) {
|
|
39
45
|
return; // page is unloading; no error surfaces
|
|
40
46
|
}
|
|
41
47
|
return next(input);
|
|
42
48
|
};
|
|
43
49
|
}
|
|
50
|
+
// A hovered link preloads through handleError too, and must not navigate on its own.
|
|
51
|
+
function isVisited(url) {
|
|
52
|
+
return url.href === location.href || url.href === navigating.to?.url.href;
|
|
53
|
+
}
|
|
44
54
|
/** Hard-reload `href` unless it already failed within the guard window. Returns true if reloading. */
|
|
45
55
|
function reloadOnce(href) {
|
|
46
56
|
try {
|
|
@@ -60,3 +70,15 @@ function reloadOnce(href) {
|
|
|
60
70
|
return false; // no sessionStorage (private mode) -> don't risk a loop
|
|
61
71
|
}
|
|
62
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Recovers what Kit leaves out after a deploy: a lazy `import()` inside a component. Reloads
|
|
75
|
+
* only when `updated.check()` confirms a new version, so a flaky network can't loop. Call it
|
|
76
|
+
* at the top of `hooks.client.js`.
|
|
77
|
+
*/
|
|
78
|
+
export function reloadOnStaleChunk() {
|
|
79
|
+
addEventListener('vite:preloadError', (event) => {
|
|
80
|
+
if (ROUTE_CHUNK_RE.test(String(event.payload)))
|
|
81
|
+
return;
|
|
82
|
+
void updated.check().then((stale) => stale && location.reload());
|
|
83
|
+
});
|
|
84
|
+
}
|
package/esm/svelte/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { ff } from './ff.svelte.js';
|
|
|
3
3
|
export type { FF_Many, FF_One, FF_Builder, FF_RepoOptions, FF_OneOptions, FF_RepoLoading, FF_Issue, ManyStrategy, AggregateOptions, QueryOptionsHelper, } from './ff.svelte.js';
|
|
4
4
|
export { infiniteScroll } from './infiniteScroll.js';
|
|
5
5
|
export type { InfiniteScrollOptions } from './infiniteScroll.js';
|
|
6
|
-
export { stackHandleClientError, withStaleDeployReload } from './handleError.js';
|
|
6
|
+
export { reloadOnStaleChunk, stackHandleClientError, withStaleDeployReload } from './handleError.js';
|
|
7
7
|
export type { HandleClientErrorMiddleware } from './handleError.js';
|
|
8
8
|
export { dialog, ffAutofocus, ffTrapFocus, resolveMessage } from './dialog.svelte.js';
|
|
9
9
|
export type { DialogResult, DialogClose, DialogOptions, DialogItem, DialogRender, ConfirmItem, PromptItem, DialogShellArgs, DialogConfirmArgs, DialogPromptArgs, } from './dialog.svelte.js';
|
package/esm/svelte/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { errorMessage, isError } from '../core/helper.js';
|
|
2
2
|
export { ff } from './ff.svelte.js';
|
|
3
3
|
export { infiniteScroll } from './infiniteScroll.js';
|
|
4
|
-
export { stackHandleClientError, withStaleDeployReload } from './handleError.js';
|
|
4
|
+
export { reloadOnStaleChunk, stackHandleClientError, withStaleDeployReload } from './handleError.js';
|
|
5
5
|
export { dialog, ffAutofocus, ffTrapFocus, resolveMessage } from './dialog.svelte.js';
|
|
6
6
|
export { default as FF_DialogManager } from './FF_DialogManager.svelte';
|
|
7
7
|
export { default as FF_Config } from './FF_Config.svelte';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ClassType, type EntityFilter, type FindOptions } from 'remult';
|
|
2
|
+
import type { RemoteQuery, RemoteResource } from '$app/server';
|
|
3
|
+
/** No `set` or `withOverride`: they would hold rows, not entities. */
|
|
4
|
+
export type RepoQuery<T> = RemoteResource<T> & Pick<RemoteQuery<T>, 'refresh'>;
|
|
5
|
+
/**
|
|
6
|
+
* `repo(E)` for reactive reads: SSR goes through the API (rules apply, as the
|
|
7
|
+
* current user) and hydration sends no query.
|
|
8
|
+
*
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <script lang="ts">
|
|
11
|
+
* import { repo } from 'remult'
|
|
12
|
+
*
|
|
13
|
+
* import { queryRepo } from 'firstly/svelte/query'
|
|
14
|
+
*
|
|
15
|
+
* let { params } = $props()
|
|
16
|
+
*
|
|
17
|
+
* let tasks = $derived(await queryRepo(Task).find({ where: { slug: params.slug } }))
|
|
18
|
+
* </script>
|
|
19
|
+
*
|
|
20
|
+
* {#each tasks as task (task.id)}
|
|
21
|
+
* <button onclick={() => repo(Task).update(task.id, { done: true })}>{task.title}</button>
|
|
22
|
+
* {/each}
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Plain `repo(E)` writes in the browser refresh the queries of `E` on screen.
|
|
26
|
+
* To do so, importing this wraps `remult.dataProvider` in the browser. Call
|
|
27
|
+
* `.refresh()` for what it can't see: BackendMethods, `include`d entities and
|
|
28
|
+
* other users' writes.
|
|
29
|
+
*
|
|
30
|
+
* A failed refresh reaches the error page, like any `.refresh()` in Kit.
|
|
31
|
+
* Long filters can exceed the URL limit: arguments travel in a GET.
|
|
32
|
+
*
|
|
33
|
+
* @experimental Needs `experimental.remoteFunctions` and
|
|
34
|
+
* `compilerOptions.experimental.async`.
|
|
35
|
+
*/
|
|
36
|
+
export declare const queryRepo: <T>(entity: ClassType<T>) => {
|
|
37
|
+
find: (options?: FindOptions<T>) => RepoQuery<T[]>;
|
|
38
|
+
findFirst: (where?: EntityFilter<T>, options?: FindOptions<T>) => RepoQuery<T | undefined>;
|
|
39
|
+
count: (where?: EntityFilter<T>) => RepoQuery<number>;
|
|
40
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { BROWSER } from 'esm-env';
|
|
2
|
+
import { Filter, remult, repo, } from 'remult';
|
|
3
|
+
import { read } from './read.remote.js';
|
|
4
|
+
const WRITES = new Set([
|
|
5
|
+
'insert',
|
|
6
|
+
'update',
|
|
7
|
+
'delete',
|
|
8
|
+
'deleteMany',
|
|
9
|
+
'updateMany',
|
|
10
|
+
'upsertMany',
|
|
11
|
+
]);
|
|
12
|
+
/** Entity key -> args of the queries on screen. */
|
|
13
|
+
const live = new Map();
|
|
14
|
+
const track = (arg) => {
|
|
15
|
+
const id = JSON.stringify(arg);
|
|
16
|
+
try {
|
|
17
|
+
$effect.pre(() => {
|
|
18
|
+
const args = live.get(arg.key) ?? live.set(arg.key, new Map()).get(arg.key);
|
|
19
|
+
const entry = args.get(id) ?? args.set(id, { arg, count: 0 }).get(id);
|
|
20
|
+
entry.count++;
|
|
21
|
+
return () => {
|
|
22
|
+
if (--entry.count === 0)
|
|
23
|
+
args.delete(id);
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// not in a reactive context: nothing on screen to refresh
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
// allSettled: a failed refresh must not fail the write that triggered it
|
|
32
|
+
const refreshLive = (key) => Promise.allSettled([...(live.get(key)?.values() ?? [])].map(({ arg }) => read(arg).refresh()));
|
|
33
|
+
const withRefresh = (dp) => new Proxy(dp, {
|
|
34
|
+
get(t, p) {
|
|
35
|
+
if (p !== 'getEntityDataProvider')
|
|
36
|
+
return Reflect.get(t, p);
|
|
37
|
+
return (entity) => new Proxy(t.getEntityDataProvider(entity), {
|
|
38
|
+
get(edp, m) {
|
|
39
|
+
const v = Reflect.get(edp, m);
|
|
40
|
+
if (typeof v !== 'function')
|
|
41
|
+
return v;
|
|
42
|
+
if (!WRITES.has(m))
|
|
43
|
+
return v.bind(edp);
|
|
44
|
+
return async (...args) => {
|
|
45
|
+
const result = await v.apply(edp, args);
|
|
46
|
+
await refreshLive(entity.key);
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
if (BROWSER)
|
|
54
|
+
remult.dataProvider = withRefresh(remult.dataProvider);
|
|
55
|
+
const mapped = (inner, arg, map) => new Proxy(inner, {
|
|
56
|
+
get(t, p) {
|
|
57
|
+
if (p === 'then') {
|
|
58
|
+
track(arg);
|
|
59
|
+
// read now: Kit pins the query in the reactive context of this getter
|
|
60
|
+
const then = t.then;
|
|
61
|
+
return (ok, err) => then.call(t, map).then(ok, err);
|
|
62
|
+
}
|
|
63
|
+
if (p === 'current')
|
|
64
|
+
return t.current === undefined ? undefined : map(t.current);
|
|
65
|
+
const v = Reflect.get(t, p);
|
|
66
|
+
return typeof v === 'function' ? v.bind(t) : v;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* `repo(E)` for reactive reads: SSR goes through the API (rules apply, as the
|
|
71
|
+
* current user) and hydration sends no query.
|
|
72
|
+
*
|
|
73
|
+
* ```svelte
|
|
74
|
+
* <script lang="ts">
|
|
75
|
+
* import { repo } from 'remult'
|
|
76
|
+
*
|
|
77
|
+
* import { queryRepo } from 'firstly/svelte/query'
|
|
78
|
+
*
|
|
79
|
+
* let { params } = $props()
|
|
80
|
+
*
|
|
81
|
+
* let tasks = $derived(await queryRepo(Task).find({ where: { slug: params.slug } }))
|
|
82
|
+
* </script>
|
|
83
|
+
*
|
|
84
|
+
* {#each tasks as task (task.id)}
|
|
85
|
+
* <button onclick={() => repo(Task).update(task.id, { done: true })}>{task.title}</button>
|
|
86
|
+
* {/each}
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* Plain `repo(E)` writes in the browser refresh the queries of `E` on screen.
|
|
90
|
+
* To do so, importing this wraps `remult.dataProvider` in the browser. Call
|
|
91
|
+
* `.refresh()` for what it can't see: BackendMethods, `include`d entities and
|
|
92
|
+
* other users' writes.
|
|
93
|
+
*
|
|
94
|
+
* A failed refresh reaches the error page, like any `.refresh()` in Kit.
|
|
95
|
+
* Long filters can exceed the URL limit: arguments travel in a GET.
|
|
96
|
+
*
|
|
97
|
+
* @experimental Needs `experimental.remoteFunctions` and
|
|
98
|
+
* `compilerOptions.experimental.async`.
|
|
99
|
+
*/
|
|
100
|
+
export const queryRepo = (entity) => {
|
|
101
|
+
const r = repo(entity);
|
|
102
|
+
const run = (method, options, map) => {
|
|
103
|
+
const where = options?.where && Filter.entityFilterToJson(r.metadata, options.where);
|
|
104
|
+
const arg = { key: r.metadata.key, method, options: { ...options, where } };
|
|
105
|
+
return mapped(read(arg), arg, map);
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
find: (options) => run('find', options, (rows) => r.fromJson(rows)),
|
|
109
|
+
findFirst: (where, options) => run('find', { ...options, where, limit: 1 }, (rows) => r.fromJson(rows)[0]),
|
|
110
|
+
count: (where) => run('count', { where }, (n) => n),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { error } from '@sveltejs/kit';
|
|
2
|
+
import { Filter, repo } from 'remult';
|
|
3
|
+
import { getRequestEvent, query } from '$app/server';
|
|
4
|
+
import { repoFetch } from '../remultApiLoad.js';
|
|
5
|
+
const READS = new Set(['find', 'count']);
|
|
6
|
+
let byKey;
|
|
7
|
+
// TODO(remult): needs a public way to resolve an entity by key
|
|
8
|
+
const allEntities = () => globalThis[Symbol.for('remult-static1')]?.allEntities ?? [];
|
|
9
|
+
export const read = query('unchecked', async ({ key, method, options }) => {
|
|
10
|
+
byKey ??= new Map(allEntities().map((e) => [repo(e).metadata.key, e]));
|
|
11
|
+
const entity = byKey.get(key);
|
|
12
|
+
// reachable by anyone: only known entities, only reads
|
|
13
|
+
if (!entity || !READS.has(method))
|
|
14
|
+
error(404);
|
|
15
|
+
// never `repo()`: on the server it reads the DB without API rules
|
|
16
|
+
const r = repoFetch(getRequestEvent().fetch)(entity);
|
|
17
|
+
const where = options?.where && Filter.entityFilterFromJson(r.metadata, options.where);
|
|
18
|
+
try {
|
|
19
|
+
if (method === 'count')
|
|
20
|
+
return await r.count(where);
|
|
21
|
+
// toJson keeps included relations as class instances
|
|
22
|
+
return JSON.parse(JSON.stringify(r.toJson(await r.find({ ...options, where }))));
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
error(e?.status ?? e?.httpStatusCode ?? 500, e?.message ?? 'Error');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
@@ -10,8 +10,16 @@ export type Repo = typeof globalRepo;
|
|
|
10
10
|
*
|
|
11
11
|
* Name the result so the intent shows at every query:
|
|
12
12
|
* ```ts
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* // +page.ts
|
|
14
|
+
* import { repoFetch } from 'firstly/svelte'
|
|
15
|
+
*
|
|
16
|
+
* import type { PageLoad } from './$types'
|
|
17
|
+
*
|
|
18
|
+
* export const load: PageLoad = async (event) => {
|
|
19
|
+
* const repoClient = repoFetch(event.fetch)
|
|
20
|
+
* const tasks = await repoClient(Task).find()
|
|
21
|
+
* return { tasks }
|
|
22
|
+
* }
|
|
15
23
|
* ```
|
|
16
24
|
*
|
|
17
25
|
* TODO(remult): framework-agnostic (only needs a fetch) - candidate to live in
|
|
@@ -22,16 +30,20 @@ export declare function repoFetch(fetch: typeof globalThis.fetch, options?: {
|
|
|
22
30
|
}): Repo;
|
|
23
31
|
/**
|
|
24
32
|
* Wrap a load: the callback receives a repo bound to `event.fetch`, then the event.
|
|
25
|
-
* Works for universal (`+page.ts`) AND server (`+page.server.ts`) loads
|
|
33
|
+
* Works for universal (`+page.ts`) AND server (`+page.server.ts`) loads:
|
|
34
|
+
* `satisfies` types the event, including `cookies` and `locals` in a server load.
|
|
26
35
|
*
|
|
27
36
|
* ```ts
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* })) satisfies PageLoad // or PageServerLoad
|
|
31
|
-
* ```
|
|
37
|
+
* // +page.ts
|
|
38
|
+
* import { loadRepo } from 'firstly/svelte'
|
|
32
39
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
40
|
+
* import type { PageLoad } from './$types'
|
|
41
|
+
*
|
|
42
|
+
* export const load = loadRepo(async (repoClient, { params }) => {
|
|
43
|
+
* const task = await repoClient(Task).findFirst({ id: params.id })
|
|
44
|
+
* return { task }
|
|
45
|
+
* }) satisfies PageLoad // or PageServerLoad
|
|
46
|
+
* ```
|
|
35
47
|
*
|
|
36
48
|
* TODO(remult): candidate to live in remult - only the event default is
|
|
37
49
|
* SvelteKit-specific, the constraint itself is structural (`{ fetch }`).
|
|
@@ -8,8 +8,16 @@ import { repo as globalRepo, remult, RestDataProvider } from 'remult';
|
|
|
8
8
|
*
|
|
9
9
|
* Name the result so the intent shows at every query:
|
|
10
10
|
* ```ts
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* // +page.ts
|
|
12
|
+
* import { repoFetch } from 'firstly/svelte'
|
|
13
|
+
*
|
|
14
|
+
* import type { PageLoad } from './$types'
|
|
15
|
+
*
|
|
16
|
+
* export const load: PageLoad = async (event) => {
|
|
17
|
+
* const repoClient = repoFetch(event.fetch)
|
|
18
|
+
* const tasks = await repoClient(Task).find()
|
|
19
|
+
* return { tasks }
|
|
20
|
+
* }
|
|
13
21
|
* ```
|
|
14
22
|
*
|
|
15
23
|
* TODO(remult): framework-agnostic (only needs a fetch) - candidate to live in
|
|
@@ -25,16 +33,20 @@ export function repoFetch(fetch, options) {
|
|
|
25
33
|
}
|
|
26
34
|
/**
|
|
27
35
|
* Wrap a load: the callback receives a repo bound to `event.fetch`, then the event.
|
|
28
|
-
* Works for universal (`+page.ts`) AND server (`+page.server.ts`) loads
|
|
36
|
+
* Works for universal (`+page.ts`) AND server (`+page.server.ts`) loads:
|
|
37
|
+
* `satisfies` types the event, including `cookies` and `locals` in a server load.
|
|
29
38
|
*
|
|
30
39
|
* ```ts
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* })) satisfies PageLoad // or PageServerLoad
|
|
34
|
-
* ```
|
|
40
|
+
* // +page.ts
|
|
41
|
+
* import { loadRepo } from 'firstly/svelte'
|
|
35
42
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
43
|
+
* import type { PageLoad } from './$types'
|
|
44
|
+
*
|
|
45
|
+
* export const load = loadRepo(async (repoClient, { params }) => {
|
|
46
|
+
* const task = await repoClient(Task).findFirst({ id: params.id })
|
|
47
|
+
* return { task }
|
|
48
|
+
* }) satisfies PageLoad // or PageServerLoad
|
|
49
|
+
* ```
|
|
38
50
|
*
|
|
39
51
|
* TODO(remult): candidate to live in remult - only the event default is
|
|
40
52
|
* SvelteKit-specific, the constraint itself is structural (`{ fetch }`).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Handle } from '@sveltejs/kit';
|
|
1
|
+
import type { Handle } from '@sveltejs/kit/hooks';
|
|
2
2
|
/**
|
|
3
3
|
* `hooks.server.js` handle that sets `Cache-Control` so browsers and CDNs cache
|
|
4
4
|
* correctly across deploys - the server-side twin of `withStaleDeployReload`.
|
|
@@ -16,7 +16,7 @@ import type { Handle } from '@sveltejs/kit';
|
|
|
16
16
|
* ```js
|
|
17
17
|
* // src/hooks.server.js
|
|
18
18
|
* import { sequence } from '@sveltejs/kit/hooks'
|
|
19
|
-
* import { handleCaching } from '
|
|
19
|
+
* import { handleCaching } from 'firstly/svelte/server'
|
|
20
20
|
*
|
|
21
21
|
* export const handle = sequence(handleCaching, ...rest)
|
|
22
22
|
* ```
|
|
@@ -17,7 +17,7 @@ const STATIC_ASSET_RE = /\.(js|css|ico|png|jpg|jpeg|gif|svg|webp|avif|woff2?|ttf
|
|
|
17
17
|
* ```js
|
|
18
18
|
* // src/hooks.server.js
|
|
19
19
|
* import { sequence } from '@sveltejs/kit/hooks'
|
|
20
|
-
* import { handleCaching } from '
|
|
20
|
+
* import { handleCaching } from 'firstly/svelte/server'
|
|
21
21
|
*
|
|
22
22
|
* export const handle = sequence(handleCaching, ...rest)
|
|
23
23
|
* ```
|
package/esm/vite/index.js
CHANGED
|
@@ -25,6 +25,11 @@ export function firstly(options) {
|
|
|
25
25
|
// })
|
|
26
26
|
// },
|
|
27
27
|
// })
|
|
28
|
+
p.push({
|
|
29
|
+
name: 'vite-plugin-firstly',
|
|
30
|
+
// Oxc leaves remult's decorators raw unless it finds experimentalDecorators in a tsconfig
|
|
31
|
+
config: () => ({ oxc: { decorator: { legacy: true } } }),
|
|
32
|
+
});
|
|
28
33
|
p.push(kitRoutes({
|
|
29
34
|
...(options?.kitRoutes ?? {}),
|
|
30
35
|
...{
|
|
@@ -50,7 +55,12 @@ export function firstly(options) {
|
|
|
50
55
|
},
|
|
51
56
|
],
|
|
52
57
|
debug: options?.stripper?.debug ?? false,
|
|
53
|
-
nullify: options?.stripper?.nullify ?? [
|
|
58
|
+
nullify: options?.stripper?.nullify ?? [
|
|
59
|
+
'$app/env/private',
|
|
60
|
+
// deprecated by Kit 3, but still resolvable
|
|
61
|
+
'$env/static/private',
|
|
62
|
+
'$env/dynamic/private',
|
|
63
|
+
],
|
|
54
64
|
}));
|
|
55
65
|
return p;
|
|
56
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Firstly, an opinionated Remult setup!",
|
|
6
6
|
"bin": {
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"engines": {
|
|
18
|
-
"node": ">=
|
|
18
|
+
"node": ">=22.17"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@sveltejs/kit": "
|
|
21
|
+
"@sveltejs/kit": "^3.0.0",
|
|
22
22
|
"remult": ">=3.3.7",
|
|
23
23
|
"svelte": ">=5"
|
|
24
24
|
},
|
|
@@ -59,6 +59,11 @@
|
|
|
59
59
|
"svelte": "./esm/svelte/index.js",
|
|
60
60
|
"default": "./esm/svelte/index.js"
|
|
61
61
|
},
|
|
62
|
+
"./svelte/query": {
|
|
63
|
+
"types": "./esm/svelte/query/index.svelte.d.ts",
|
|
64
|
+
"svelte": "./esm/svelte/query/index.svelte.js",
|
|
65
|
+
"default": "./esm/svelte/query/index.svelte.js"
|
|
66
|
+
},
|
|
62
67
|
"./svelte/server": {
|
|
63
68
|
"types": "./esm/svelte/server/index.d.ts",
|
|
64
69
|
"default": "./esm/svelte/server/index.js"
|