firstly 0.4.4 → 0.5.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 +26 -0
- package/esm/core/FF_Entity.d.ts +2 -0
- package/esm/core/FF_Filter.d.ts +17 -2
- package/esm/core/FF_Filter.js +18 -2
- package/esm/core/containsWords.d.ts +13 -0
- package/esm/core/containsWords.js +27 -0
- package/esm/core/httpClientStack.d.ts +18 -0
- package/esm/core/httpClientStack.js +26 -0
- package/esm/formats/index.d.ts +1 -1
- package/esm/formats/index.js +1 -1
- package/esm/formats/strings.d.ts +7 -0
- package/esm/formats/strings.js +12 -0
- package/esm/index.d.ts +2 -0
- package/esm/index.js +1 -0
- package/esm/svelte/FF_Repo.svelte.d.ts +171 -56
- package/esm/svelte/FF_Repo.svelte.js +280 -161
- package/esm/svelte/index.d.ts +4 -1
- package/esm/svelte/index.js +2 -1
- package/esm/svelte/infiniteScroll.d.ts +31 -0
- package/esm/svelte/infiniteScroll.js +40 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#274](https://github.com/jycouet/firstly/pull/274) [`6114148`](https://github.com/jycouet/firstly/commit/61141482a06f0a006ae148f0645146c073a2fb3c) Thanks [@jycouet](https://github.com/jycouet)! - **BREAKING (svelte): `FF_Repo` class -> `ffRepo()` factory.** The reactive repo wrapper now takes a reactive options getter and a mode (`load` / `listen` / `paginate` / `one`), with built-in mutations (no-arg `save()`/`delete()` target the loaded `item`), client-side list reconcilers (`addItem`/`updateItem`/`removeItem`, with `addItem` positioning), `firstOnce`/`draft`, and permissions via `r.meta`. The old `new FF_Repo(E, { findOptions })` class is removed.
|
|
8
|
+
|
|
9
|
+
One rule: anything not under `.repo` is reactive; every imperative read/write lives on `.repo` (the plain remult repo). The builder no longer hoists `findFirst`/`findId`/`insert`/... - use `ffRepo(E).repo.*`.
|
|
10
|
+
|
|
11
|
+
Also new: `infiniteScroll` (svelte attachment, pairs with `paginate`), `stackHttpClient`/`withHeader` (core), `FF_Filter.containsWords` (multi-field search filter), `splitTrim` (formats), and exported types including the umbrella `FF_Repo` handle plus `FF_RepoBuilder`/`FF_RepoLoad`/`FF_RepoLive`/`FF_RepoPaginate`/`FF_RepoOne`/`QueryOptionsHelper`/`AggregateOptions`.
|
|
12
|
+
|
|
13
|
+
Migration (see `/docs/svelte/ff-repo`):
|
|
14
|
+
- `new FF_Repo(E, { findOptions: { where } })` -> `ffRepo(E).load(() => ({ where }))`
|
|
15
|
+
- `new FF_Repo(E, { queryOptions })` + `.query()/.queryMore()/.queryRefresh()` -> `ffRepo(E).paginate(() => ({ ... }))` + `.more()/.refresh()`
|
|
16
|
+
- `r.globalError` -> `r.error`
|
|
17
|
+
- `r.fields` -> `r.meta.fields`; `r.metadata.apiInsertAllowed()` -> `r.meta.apiInsertAllowed()`
|
|
18
|
+
- `repo(r.ent).update(...)` / `.insert(...)` -> `r.update(...)` / `r.insert(...)`
|
|
19
|
+
- `r.aggregates.$count` unchanged; `skipAutoFetch` -> `enabled: false`
|
|
20
|
+
|
|
21
|
+
## 0.4.5
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- [#270](https://github.com/jycouet/firstly/pull/270) [`ba5eec6`](https://github.com/jycouet/firstly/commit/ba5eec6b0099127c9b5424dd3fb44184b4c70b28) Thanks [@jycouet](https://github.com/jycouet)! - fix(core): add explicit return type to `FF_Entity` so its `.d.ts` is emitted.
|
|
26
|
+
|
|
27
|
+
The inferred return type referenced a non-portable remult internal, so svelte-package silently skipped generating `FF_Entity.d.ts`. Consumers using the published package got `FF_Entity` typed as `any`, which made every entity option callback (`saving`, `displayValue`, ...) implicitly `any`.
|
|
28
|
+
|
|
3
29
|
## 0.4.4
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/esm/core/FF_Filter.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Filter helpers that build a remult `EntityFilter`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
|
|
5
|
+
* pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
|
|
6
|
+
* is a search-box helper (pairs with `ffRepo(...).load/paginate`).
|
|
5
7
|
*
|
|
6
8
|
* Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
|
|
7
9
|
* autocompletion and type-safety on the column name. Without a generic the
|
|
@@ -52,4 +54,17 @@ export declare const FF_Filter: {
|
|
|
52
54
|
col?: keyof T & string;
|
|
53
55
|
roles: string[];
|
|
54
56
|
}) => any;
|
|
57
|
+
/**
|
|
58
|
+
* Build a search filter where every word must match (AND) and each word may
|
|
59
|
+
* match any of the given fields (OR), case-insensitive `$contains`. Word order
|
|
60
|
+
* and which field holds which word don't matter - handy for "NOM Prénom" search.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const r = ffRepo(User).load(() => ({
|
|
65
|
+
* where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
|
|
66
|
+
* }))
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
containsWords: <Entity>(fields: import("remult").FieldMetadata<unknown, Entity>[], search: string) => import("remult").EntityFilter<Entity>;
|
|
55
70
|
};
|
package/esm/core/FF_Filter.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { remult } from 'remult';
|
|
2
|
+
import { containsWords } from './containsWords.js';
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
+
* Filter helpers that build a remult `EntityFilter`.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
+
* `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
|
|
7
|
+
* pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
|
|
8
|
+
* is a search-box helper (pairs with `ffRepo(...).load/paginate`).
|
|
6
9
|
*
|
|
7
10
|
* Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
|
|
8
11
|
* autocompletion and type-safety on the column name. Without a generic the
|
|
@@ -54,4 +57,17 @@ export const FF_Filter = {
|
|
|
54
57
|
return {};
|
|
55
58
|
return { [col]: [remult.user?.id] };
|
|
56
59
|
},
|
|
60
|
+
/**
|
|
61
|
+
* Build a search filter where every word must match (AND) and each word may
|
|
62
|
+
* match any of the given fields (OR), case-insensitive `$contains`. Word order
|
|
63
|
+
* and which field holds which word don't matter - handy for "NOM Prénom" search.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* const r = ffRepo(User).load(() => ({
|
|
68
|
+
* where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
|
|
69
|
+
* }))
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
containsWords,
|
|
57
73
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EntityFilter, FieldMetadata } from 'remult';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a filter where every word in `search` must match (AND), and each word
|
|
4
|
+
* may match any of the given fields (OR), using case-insensitive `$contains`.
|
|
5
|
+
*
|
|
6
|
+
* E.g. "dupont marie" over [name, sesa] =>
|
|
7
|
+
* (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
|
|
8
|
+
*
|
|
9
|
+
* So word order and which field holds which word don't matter - handy for
|
|
10
|
+
* "NOM Prénom" style search across several columns. Pairs with `ffRepo`:
|
|
11
|
+
* `ffRepo(User).load(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const containsWords: <Entity>(fields: FieldMetadata<unknown, Entity>[], search: string) => EntityFilter<Entity>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a filter where every word in `search` must match (AND), and each word
|
|
3
|
+
* may match any of the given fields (OR), using case-insensitive `$contains`.
|
|
4
|
+
*
|
|
5
|
+
* E.g. "dupont marie" over [name, sesa] =>
|
|
6
|
+
* (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
|
|
7
|
+
*
|
|
8
|
+
* So word order and which field holds which word don't matter - handy for
|
|
9
|
+
* "NOM Prénom" style search across several columns. Pairs with `ffRepo`:
|
|
10
|
+
* `ffRepo(User).load(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
|
|
11
|
+
*/
|
|
12
|
+
export const containsWords = (fields, search) => {
|
|
13
|
+
const words = (search ?? '').split(' ').filter((s) => s.length > 0);
|
|
14
|
+
if (words.length === 0 || fields.length === 0) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
if (fields.length === 1) {
|
|
18
|
+
return {
|
|
19
|
+
$and: words.map((s) => ({ [fields[0].key]: { $contains: s } })),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
$and: words.map((s) => ({
|
|
24
|
+
$or: fields.map((f) => ({ [f.key]: { $contains: s } })),
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type HttpClientFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
export type HttpClientMiddleware = (next: HttpClientFetch) => HttpClientFetch;
|
|
3
|
+
/**
|
|
4
|
+
* Compose `fetch` middlewares into a single `fetch`-shaped function. Middlewares
|
|
5
|
+
* run outermost-first (the first argument wraps the rest). Useful to inject auth
|
|
6
|
+
* headers, correlation ids, retries, logging, ... around a base `fetch`.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const client = stackHttpClient(
|
|
10
|
+
* withHeader('x-correlation-id', () => crypto.randomUUID()),
|
|
11
|
+
* withHeader('authorization', () => `Bearer ${token}`),
|
|
12
|
+
* )
|
|
13
|
+
* await client('/api/...')
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function stackHttpClient(...middlewares: HttpClientMiddleware[]): HttpClientFetch;
|
|
17
|
+
/** Middleware that sets a request header from `getValue()` when it returns a value. */
|
|
18
|
+
export declare function withHeader(name: string, getValue: () => string | undefined | null): HttpClientMiddleware;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose `fetch` middlewares into a single `fetch`-shaped function. Middlewares
|
|
3
|
+
* run outermost-first (the first argument wraps the rest). Useful to inject auth
|
|
4
|
+
* headers, correlation ids, retries, logging, ... around a base `fetch`.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const client = stackHttpClient(
|
|
8
|
+
* withHeader('x-correlation-id', () => crypto.randomUUID()),
|
|
9
|
+
* withHeader('authorization', () => `Bearer ${token}`),
|
|
10
|
+
* )
|
|
11
|
+
* await client('/api/...')
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function stackHttpClient(...middlewares) {
|
|
15
|
+
return middlewares.reduceRight((next, mw) => mw(next), (input, init) => fetch(input, init));
|
|
16
|
+
}
|
|
17
|
+
/** Middleware that sets a request header from `getValue()` when it returns a value. */
|
|
18
|
+
export function withHeader(name, getValue) {
|
|
19
|
+
return (next) => async (input, init) => {
|
|
20
|
+
const headers = new Headers(init?.headers);
|
|
21
|
+
const value = getValue();
|
|
22
|
+
if (value)
|
|
23
|
+
headers.set(name, value);
|
|
24
|
+
return next(input, { ...init, headers });
|
|
25
|
+
};
|
|
26
|
+
}
|
package/esm/formats/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { displayCurrency, displayCurrencyK, displayPercent, displayCurrencyWOSuffix, } from './numbers.js';
|
|
2
|
-
export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, } from './strings.js';
|
|
2
|
+
export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, splitTrim, } from './strings.js';
|
|
3
3
|
export { offsetedToPlainDate, plainDateCompare, isBetween, dateISOToPlainDate } from './dates.js';
|
|
4
4
|
export type { KitPlainDateRange } from './dates.js';
|
package/esm/formats/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { displayCurrency, displayCurrencyK, displayPercent, displayCurrencyWOSuffix, } from './numbers.js';
|
|
2
|
-
export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, } from './strings.js';
|
|
2
|
+
export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, splitTrim, } from './strings.js';
|
|
3
3
|
export { offsetedToPlainDate, plainDateCompare, isBetween, dateISOToPlainDate } from './dates.js';
|
package/esm/formats/strings.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a string into a flat list of trimmed, non-empty tokens. The default
|
|
3
|
+
* separator handles comma- and newline-separated input.
|
|
4
|
+
*
|
|
5
|
+
* `"a, b\n,c"` -> `["a", "b", "c"]`
|
|
6
|
+
*/
|
|
7
|
+
export declare const splitTrim: (input?: string | null, separator?: string | RegExp) => string[];
|
|
1
8
|
export declare const formatNumber: (number: number, digitNumber?: number) => string;
|
|
2
9
|
export declare function displayPhone(_entity: any, value: string | undefined): string;
|
|
3
10
|
export declare const arrToStr: (arr: string | undefined | (string | undefined)[]) => string;
|
package/esm/formats/strings.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/** Default separator for `splitTrim`: any run of commas and/or newlines. */
|
|
2
|
+
const COMMA_OR_NEWLINE = /[,\n]+/;
|
|
3
|
+
/**
|
|
4
|
+
* Split a string into a flat list of trimmed, non-empty tokens. The default
|
|
5
|
+
* separator handles comma- and newline-separated input.
|
|
6
|
+
*
|
|
7
|
+
* `"a, b\n,c"` -> `["a", "b", "c"]`
|
|
8
|
+
*/
|
|
9
|
+
export const splitTrim = (input, separator = COMMA_OR_NEWLINE) => (input ?? '')
|
|
10
|
+
.split(separator)
|
|
11
|
+
.map((s) => s.trim())
|
|
12
|
+
.filter(Boolean);
|
|
1
13
|
export const formatNumber = (number, digitNumber = 2) => {
|
|
2
14
|
if (number === undefined || number === null || isNaN(number)) {
|
|
3
15
|
const value = 0;
|
package/esm/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export { errorMessage, isError } from './core/helper.js';
|
|
|
15
15
|
export { tryCatch, tryCatchSync } from './core/tryCatch.js';
|
|
16
16
|
export type { ResolvedType, UnArray, RecursivePartial } from './core/types.js';
|
|
17
17
|
export { tw } from './core/tailwind.js';
|
|
18
|
+
export { stackHttpClient, withHeader } from './core/httpClientStack.js';
|
|
19
|
+
export type { HttpClientFetch, HttpClientMiddleware } from './core/httpClientStack.js';
|
|
18
20
|
export { FF_LogToConsole } from './SqlDatabase/FF_LogToConsole.js';
|
|
19
21
|
export { FilterEntity } from './virtual/FilterEntity.js';
|
|
20
22
|
export { UIEntity } from './virtual/UIEntity.js';
|
package/esm/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { FF_Validators, createValidators } from './core/FF_Validators.js';
|
|
|
15
15
|
export { errorMessage, isError } from './core/helper.js';
|
|
16
16
|
export { tryCatch, tryCatchSync } from './core/tryCatch.js';
|
|
17
17
|
export { tw } from './core/tailwind.js';
|
|
18
|
+
export { stackHttpClient, withHeader } from './core/httpClientStack.js';
|
|
18
19
|
// Misc primitives still exposed from the root.
|
|
19
20
|
export { FF_LogToConsole } from './SqlDatabase/FF_LogToConsole.js';
|
|
20
21
|
export { FilterEntity } from './virtual/FilterEntity.js';
|
|
@@ -1,18 +1,83 @@
|
|
|
1
|
-
import { type ClassType, type
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { type ClassType, type EntityFilter, type EntityMetadata, type EntityOrderBy, type GroupByOptions, type GroupByResult, type MembersOnly, type MembersToInclude, type NumericKeys, type QueryOptions, type Repository } from 'remult';
|
|
2
|
+
/**
|
|
3
|
+
* `ffRepo` - thin reactive wrapper around a Remult `repo`, exposing its results as
|
|
4
|
+
* Svelte runes. Pick the mode with a verb:
|
|
5
|
+
*
|
|
6
|
+
* ffRepo(E).load(() => ({ where })) // load - one-shot list + refresh()
|
|
7
|
+
* ffRepo(E).listen(() => ({ where })) // live - liveQuery, auto-updates
|
|
8
|
+
* ffRepo(E).paginate(() => ({ where })) // paginate - more() / hasNextPage / aggregates
|
|
9
|
+
* ffRepo(E).one(() => ({ where })) // one - reactive single record in `item`
|
|
10
|
+
*
|
|
11
|
+
* Two surfaces, one rule: anything NOT under `.repo` is reactive (a verb returns a
|
|
12
|
+
* runes handle; that handle's writes sync its own state). Anything under `.repo` is
|
|
13
|
+
* the plain remult repo - imperative, returns Promises, touches no runes state.
|
|
14
|
+
*
|
|
15
|
+
* The options getter is reactive: change `where` (e.g. a search box), `orderBy`,
|
|
16
|
+
* `enabled`, etc. and the query re-runs - `listen` re-subscribes (the old
|
|
17
|
+
* subscription is torn down), `load`/`paginate`/`one` re-fetch and ignore any
|
|
18
|
+
* stale in-flight response. `orderBy` defaults to the entity's `defaultOrderBy`.
|
|
19
|
+
*
|
|
20
|
+
* Getter hygiene: read SvelteKit load `data` through a `$derived`
|
|
21
|
+
* (`const did = $derived(data.targetDid)`), never raw inside the getter. A derived
|
|
22
|
+
* only propagates on value change, so a parent layout load revalidating - e.g. an
|
|
23
|
+
* SP URL write re-running url-dependent loads on every filter - hands you a new
|
|
24
|
+
* `data` object but does NOT re-fetch unless the value actually changed; a real
|
|
25
|
+
* change (route param switch) still does. Reading `data.x` raw re-fetches on every
|
|
26
|
+
* revalidation (a duplicate request per filter).
|
|
27
|
+
*
|
|
28
|
+
* `enabled: false` skips the query entirely (keeps the last result) and runs it
|
|
29
|
+
* the moment it flips true - use it for search-min-length, tab visibility,
|
|
30
|
+
* dependent queries, or a manual button trigger.
|
|
31
|
+
*
|
|
32
|
+
* Mutations on a handle (`insert`/`update`/`save`/`delete`) flip `loading` and keep
|
|
33
|
+
* the result in sync - in `live` mode the liveQuery does it; in `load`/`paginate` a
|
|
34
|
+
* delete is removed locally and an insert/update triggers a keep-count re-fetch; in
|
|
35
|
+
* `one` any write re-fetches the single record. `save()`/`delete()` with no argument
|
|
36
|
+
* target the current `item` (pairs with `one`/`create()`). For a raw write that syncs
|
|
37
|
+
* nothing, use `.repo` (e.g. `ffRepo(E).repo.insert(...)`).
|
|
38
|
+
*
|
|
39
|
+
* The factory's return type is mode-specific, so e.g. `.more()` doesn't exist on
|
|
40
|
+
* a `listen()` handle. Methods also throw if reached via a cast in the wrong mode.
|
|
41
|
+
*
|
|
42
|
+
* Reactive vs imperative: the reactive verbs take a *getter* (`() => ({ ... })`) and
|
|
43
|
+
* return a runes handle; they build an `$effect`, so they must be created during
|
|
44
|
+
* component init. For a one-off read/write in a click handler / async fn (no runes
|
|
45
|
+
* context) go through `.repo` (plain remult): `ffRepo(E).repo.findFirst(where)`,
|
|
46
|
+
* `ffRepo(E).repo.findId(id)`, `ffRepo(E).repo.find(...)`.
|
|
47
|
+
*
|
|
48
|
+
* Tip: prefer `.paginate()` whenever you want a total - it returns `aggregates.$count`
|
|
49
|
+
* for free in the same request. `load`/`listen`/`one` don't count; for a one-off count
|
|
50
|
+
* use `ffRepo(E).repo.count(where)`.
|
|
51
|
+
*/
|
|
52
|
+
/** The aggregate request shape - remult's `GroupByOptions` minus the grouping/paging keys. */
|
|
53
|
+
export type AggregateOptions<Entity> = Omit<GroupByOptions<Entity, never, NumericKeys<Entity>[], NumericKeys<Entity>[], (keyof MembersOnly<Entity>)[], (keyof MembersOnly<Entity>)[], (keyof MembersOnly<Entity>)[]>, 'group' | 'orderBy' | 'where' | 'limit' | 'page'>;
|
|
54
|
+
/**
|
|
55
|
+
* Remult `QueryOptions` plus the `aggregate` request shape. Exported for callers
|
|
56
|
+
* that build query options outside `ffRepo` (e.g. a generic table component).
|
|
57
|
+
*/
|
|
58
|
+
export type QueryOptionsHelper<Entity> = QueryOptions<Entity> & {
|
|
59
|
+
aggregate?: AggregateOptions<Entity>;
|
|
4
60
|
};
|
|
5
|
-
type
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
61
|
+
export type FF_RepoOptions<Entity> = {
|
|
62
|
+
where?: EntityFilter<Entity>;
|
|
63
|
+
orderBy?: EntityOrderBy<Entity>;
|
|
64
|
+
/** paginate: rows per page (default 25). */
|
|
65
|
+
pageSize?: number;
|
|
66
|
+
/** find/one/live: cap the rows returned. No default - returns every matching row. */
|
|
67
|
+
limit?: number;
|
|
68
|
+
include?: MembersToInclude<Entity>;
|
|
69
|
+
/** When false, the query is skipped (last result kept) until it flips true. */
|
|
70
|
+
enabled?: boolean;
|
|
71
|
+
/** Aggregations to compute alongside the page (paginate mode only). `$count` is always returned. */
|
|
72
|
+
aggregate?: AggregateOptions<Entity>;
|
|
11
73
|
};
|
|
12
|
-
type
|
|
13
|
-
|
|
74
|
+
type Getter<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = () => O;
|
|
75
|
+
/** `$count` is always present; richer keys appear only for the requested `aggregate`. */
|
|
76
|
+
type EmptyAggregateResult = {
|
|
77
|
+
$count: number;
|
|
14
78
|
};
|
|
15
|
-
|
|
79
|
+
/** The typed aggregate result for a given options object (paginate mode). */
|
|
80
|
+
type ExtractAggregateResult<Entity, O extends FF_RepoOptions<Entity>> = O extends {
|
|
16
81
|
aggregate: infer A;
|
|
17
82
|
} ? GroupByResult<Entity, never, A extends {
|
|
18
83
|
sum?: infer S;
|
|
@@ -20,57 +85,107 @@ type ExtractAggregateResult<Entity, Options extends QueryOptionsHelper<Entity>>
|
|
|
20
85
|
avg?: infer V;
|
|
21
86
|
} ? (V extends NumericKeys<Entity>[] ? V : never) : never, A extends {
|
|
22
87
|
min?: infer M;
|
|
23
|
-
} ? (M extends
|
|
88
|
+
} ? (M extends NumericKeys<Entity>[] ? M : never) : never, A extends {
|
|
24
89
|
max?: infer X;
|
|
25
|
-
} ? (X extends
|
|
90
|
+
} ? (X extends NumericKeys<Entity>[] ? X : never) : never, A extends {
|
|
26
91
|
distinctCount?: infer D;
|
|
27
92
|
} ? D extends (keyof MembersOnly<Entity>)[] ? D : never : never> : EmptyAggregateResult;
|
|
28
|
-
export
|
|
93
|
+
export type FF_RepoLoading = {
|
|
94
|
+
init: boolean;
|
|
95
|
+
fetching: boolean;
|
|
96
|
+
more: boolean;
|
|
97
|
+
saving: boolean;
|
|
98
|
+
deleting: boolean;
|
|
99
|
+
};
|
|
100
|
+
type Mode = 'live' | 'load' | 'paginate' | 'one';
|
|
101
|
+
/**
|
|
102
|
+
* The reactive handle implementation. Not exported directly - consumers use a per-mode
|
|
103
|
+
* alias (`FF_RepoLoad`/`FF_RepoLive`/`FF_RepoPaginate`/`FF_RepoOne`) or the umbrella
|
|
104
|
+
* union `FF_Repo` (any mode). Each verb returns the Omit'd per-mode view of this.
|
|
105
|
+
*/
|
|
106
|
+
declare class FF_RepoHandle<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> {
|
|
29
107
|
#private;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
metadata: Repository<Entity>['metadata'];
|
|
33
|
-
loading: Loading;
|
|
34
|
-
items: Entity[] | undefined;
|
|
35
|
-
aggregates: ExtractAggregateResult<Entity, QueryOptions> | undefined;
|
|
36
|
-
hasNextPage: boolean | undefined;
|
|
108
|
+
items: Entity[];
|
|
109
|
+
/** Single-record slot: the loaded row in `one` mode, or a create/edit draft (see `create`). */
|
|
37
110
|
item: Entity | undefined;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
};
|
|
50
|
-
}) & {
|
|
51
|
-
item?: Entity;
|
|
52
|
-
});
|
|
53
|
-
find(options: FindOptions<Entity>): Promise<Entity[] | undefined>;
|
|
54
|
-
query(options: Pick<QueryOptionsHelper<Entity>, 'where' | 'orderBy'>): Promise<{
|
|
55
|
-
items: Entity[];
|
|
56
|
-
aggregates: ExtractAggregateResult<Entity, QueryOptions>;
|
|
57
|
-
hasNextPage: boolean;
|
|
58
|
-
} | undefined>;
|
|
59
|
-
queryMore(): Promise<{
|
|
60
|
-
items: Entity[] | undefined;
|
|
61
|
-
aggregates: ExtractAggregateResult<Entity, QueryOptions>;
|
|
62
|
-
hasNextPage: boolean;
|
|
63
|
-
} | undefined>;
|
|
111
|
+
loading: FF_RepoLoading;
|
|
112
|
+
error: string | undefined;
|
|
113
|
+
hasNextPage: boolean;
|
|
114
|
+
/** Aggregations for the whole query (paginate mode). `aggregates.$count` is the total row count. */
|
|
115
|
+
aggregates: ExtractAggregateResult<Entity, O> | undefined;
|
|
116
|
+
first: Entity | null;
|
|
117
|
+
constructor(r: Repository<Entity>, opts: Getter<Entity, O>, mode: Mode);
|
|
118
|
+
/** Re-run the current query (load/paginate/one), back to the first page. */
|
|
119
|
+
refresh(): Promise<void>;
|
|
120
|
+
/** Load and append the next page (paginate mode). */
|
|
121
|
+
more(): Promise<void>;
|
|
64
122
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
123
|
+
* Run `fn` once, the first time a row is known (first non-null `first`).
|
|
124
|
+
* Never fires while the result is empty - so it's robust to liveQuery emitting
|
|
125
|
+
* an empty snapshot before the data lands. Nothing to seed from an empty result.
|
|
67
126
|
*/
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} | undefined>;
|
|
127
|
+
firstOnce(fn: (latest: Entity) => void): void;
|
|
128
|
+
/** Reactive, bindable editor state seeded once from the latest row. */
|
|
129
|
+
draft<D extends Record<string, unknown>>(seed: (latest: Entity | null) => D): D;
|
|
130
|
+
/** Create a new unsaved entity into the `item` slot (for an edit form). */
|
|
73
131
|
create(...args: Parameters<Repository<Entity>['create']>): Entity;
|
|
74
|
-
|
|
132
|
+
insert(...args: Parameters<Repository<Entity>['insert']>): Promise<Entity>;
|
|
133
|
+
update(...args: Parameters<Repository<Entity>['update']>): Promise<Entity>;
|
|
134
|
+
/** Save. With no argument, saves the current `item` (e.g. a bound `one`/`create` form). */
|
|
135
|
+
save(...args: [] | Parameters<Repository<Entity>['save']>): Promise<Entity>;
|
|
136
|
+
/** Delete. With no argument, deletes the current `item`. */
|
|
137
|
+
delete(...args: [] | Parameters<Repository<Entity>['delete']>): Promise<void>;
|
|
138
|
+
deleteMany(...args: Parameters<Repository<Entity>['deleteMany']>): Promise<number>;
|
|
139
|
+
/** Insert into `items` at `top` (default) / `bottom` / an index (`-1` = last). +1 to `$count`. */
|
|
140
|
+
addItem(item: Entity, options?: {
|
|
141
|
+
at?: 'top' | 'bottom' | number;
|
|
142
|
+
}): void;
|
|
143
|
+
/** Replace the row whose id matches `item`'s id (no `$count` change). */
|
|
144
|
+
updateItem(item: Entity): void;
|
|
145
|
+
/** Drop the matching row (pass an id or the item). -1 to `$count`. */
|
|
146
|
+
removeItem(idOrItem: Parameters<Repository<Entity>['delete']>[0]): void;
|
|
147
|
+
/**
|
|
148
|
+
* The entity's remult metadata - the single escape hatch for everything not on
|
|
149
|
+
* this handle: permissions (`apiInsertAllowed()`, `apiUpdateAllowed(item)`,
|
|
150
|
+
* `apiDeleteAllowed(item)`, `apiReadAllowed`), `fields`, `idMetadata`, `options`,
|
|
151
|
+
* `key`. Reflects the current `remult.user`.
|
|
152
|
+
*/
|
|
153
|
+
get meta(): EntityMetadata<Entity>;
|
|
154
|
+
/** Escape hatch to the underlying repo (count, findId, upsert, projections, ...). */
|
|
155
|
+
get repo(): Repository<Entity>;
|
|
75
156
|
}
|
|
157
|
+
/** load: one-shot list; `refresh()` to re-run. No paging / aggregates. */
|
|
158
|
+
export type FF_RepoLoad<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates'>;
|
|
159
|
+
/** live: reactive subscription, auto-updates. No manual refresh / paging / aggregates / list reconcilers (the liveQuery does it). */
|
|
160
|
+
export type FF_RepoLive<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'refresh' | 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem'>;
|
|
161
|
+
/** paginate: `more()` / `hasNextPage` / `aggregates`. No `first`/`firstOnce`/`draft` (paged ≠ latest). */
|
|
162
|
+
export type FF_RepoPaginate<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'first' | 'firstOnce' | 'draft'>;
|
|
163
|
+
/** one: a single reactive record in `item` (+ `first`). No paging / aggregates / list reconcilers. */
|
|
164
|
+
export type FF_RepoOne<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem'>;
|
|
165
|
+
/**
|
|
166
|
+
* Umbrella handle type - any mode. Use for a component prop that accepts a
|
|
167
|
+
* `load`/`listen`/`paginate`/`one` handle (`r: FF_Repo<T>`). It exposes the surface
|
|
168
|
+
* common to every mode (`items`/`item`/`loading`/`error`/`meta`/`repo` + the writes);
|
|
169
|
+
* mode-specific members (`more`/`hasNextPage`/`aggregates`/`refresh`/`first`/`firstOnce`/
|
|
170
|
+
* `draft`/`addItem`/`updateItem`/`removeItem`) require the matching per-mode type.
|
|
171
|
+
*/
|
|
172
|
+
export type FF_Repo<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = FF_RepoLoad<Entity, O> | FF_RepoLive<Entity, O> | FF_RepoPaginate<Entity, O> | FF_RepoOne<Entity, O>;
|
|
173
|
+
type StrictGetter<Entity, O extends FF_RepoOptions<Entity>> = () => O & Record<Exclude<keyof O, keyof FF_RepoOptions<Entity>>, never>;
|
|
174
|
+
/**
|
|
175
|
+
* The builder returned by `ffRepo(E)`. Two surfaces only: the reactive verbs
|
|
176
|
+
* (`load`/`listen`/`paginate`/`one`), and `.repo` - the plain remult repo for every
|
|
177
|
+
* imperative read/write (`findFirst`, `findId`, `find`, `insert`, `update`, `save`,
|
|
178
|
+
* `delete`, `count`, `upsert`, ...). `.meta` is a shortcut to `repo.metadata`.
|
|
179
|
+
*/
|
|
180
|
+
export type FF_RepoBuilder<Entity> = {
|
|
181
|
+
load: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoLoad<Entity, O>;
|
|
182
|
+
listen: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoLive<Entity, O>;
|
|
183
|
+
paginate: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoPaginate<Entity, O>;
|
|
184
|
+
one: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_RepoOne<Entity, O>;
|
|
185
|
+
/** The entity's remult metadata (permissions, fields, key). Shortcut to `repo.metadata`. */
|
|
186
|
+
readonly meta: EntityMetadata<Entity>;
|
|
187
|
+
/** The underlying remult repo - every imperative read/write lives here. */
|
|
188
|
+
readonly repo: Repository<Entity>;
|
|
189
|
+
};
|
|
190
|
+
export declare function ffRepo<Entity>(entity: ClassType<Entity>): FF_RepoBuilder<Entity>;
|
|
76
191
|
export {};
|
|
@@ -1,193 +1,312 @@
|
|
|
1
1
|
import { repo as remultRepo, } from 'remult';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The reactive handle implementation. Not exported directly - consumers use a per-mode
|
|
4
|
+
* alias (`FF_RepoLoad`/`FF_RepoLive`/`FF_RepoPaginate`/`FF_RepoOne`) or the umbrella
|
|
5
|
+
* union `FF_Repo` (any mode). Each verb returns the Omit'd per-mode view of this.
|
|
6
|
+
*/
|
|
7
|
+
class FF_RepoHandle {
|
|
6
8
|
#repo;
|
|
9
|
+
#opts;
|
|
10
|
+
#mode;
|
|
11
|
+
#defaultOrderBy;
|
|
7
12
|
#paginator;
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
#seq = 0;
|
|
14
|
+
items = $state([]);
|
|
15
|
+
/** Single-record slot: the loaded row in `one` mode, or a create/edit draft (see `create`). */
|
|
16
|
+
item = $state(undefined);
|
|
12
17
|
loading = $state({
|
|
13
|
-
init:
|
|
18
|
+
init: true,
|
|
14
19
|
fetching: false,
|
|
15
20
|
more: false,
|
|
16
21
|
saving: false,
|
|
17
22
|
deleting: false,
|
|
18
23
|
});
|
|
19
|
-
|
|
24
|
+
error = $state(undefined);
|
|
25
|
+
hasNextPage = $state(false);
|
|
26
|
+
/** Aggregations for the whole query (paginate mode). `aggregates.$count` is the total row count. */
|
|
20
27
|
aggregates = $state(undefined);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
this
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this.loadingEnd();
|
|
54
|
-
}
|
|
28
|
+
first = $derived(this.items[0] ?? null);
|
|
29
|
+
constructor(r, opts, mode) {
|
|
30
|
+
this.#repo = r;
|
|
31
|
+
this.#opts = opts;
|
|
32
|
+
this.#mode = mode;
|
|
33
|
+
this.#defaultOrderBy = r.metadata.options.defaultOrderBy;
|
|
34
|
+
$effect(() => {
|
|
35
|
+
const o = this.#resolve();
|
|
36
|
+
if (o.enabled === false) {
|
|
37
|
+
this.loading.init = false;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (mode === 'live') {
|
|
41
|
+
// Pass orderBy so liveQuery re-sorts incrementally-added rows too;
|
|
42
|
+
// without it a freshly inserted row is appended and `first` goes stale.
|
|
43
|
+
const unsub = this.#repo
|
|
44
|
+
.liveQuery({ where: o.where, orderBy: o.orderBy, limit: o.limit, include: o.include })
|
|
45
|
+
.subscribe({
|
|
46
|
+
next: (info) => {
|
|
47
|
+
this.items = info.items;
|
|
48
|
+
this.loading.init = false;
|
|
49
|
+
},
|
|
50
|
+
error: (e) => {
|
|
51
|
+
this.error = e instanceof Error ? e.message : String(e);
|
|
52
|
+
this.loading.init = false;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return () => unsub();
|
|
56
|
+
}
|
|
57
|
+
// load | paginate | one: (re)fetch; a newer opts() invalidates older responses.
|
|
58
|
+
void this.#load(o, ++this.#seq);
|
|
59
|
+
});
|
|
55
60
|
}
|
|
56
|
-
|
|
61
|
+
#resolve() {
|
|
62
|
+
const o = this.#opts();
|
|
63
|
+
return { ...o, orderBy: o.orderBy ?? this.#defaultOrderBy };
|
|
64
|
+
}
|
|
65
|
+
async #load(o, seq, keepCount) {
|
|
57
66
|
this.loading.fetching = true;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
this.error = undefined;
|
|
68
|
+
try {
|
|
69
|
+
if (this.#mode === 'paginate') {
|
|
70
|
+
// One request returns the page AND the aggregates ($count always, plus any
|
|
71
|
+
// requested): on the client REST proxy remult fetches both together. It sets
|
|
72
|
+
// `hasNextPage` from whether a full page came back (no count probe); `more()`
|
|
73
|
+
// then fetches the next page via a keyset cursor (orderBy + PK).
|
|
74
|
+
const p = await this.#repo
|
|
75
|
+
.query({
|
|
76
|
+
where: o.where,
|
|
77
|
+
orderBy: o.orderBy,
|
|
78
|
+
pageSize: keepCount ?? o.pageSize ?? 25,
|
|
79
|
+
include: o.include,
|
|
80
|
+
aggregate: { ...o.aggregate },
|
|
81
|
+
})
|
|
82
|
+
.paginator();
|
|
83
|
+
if (seq !== this.#seq)
|
|
84
|
+
return;
|
|
85
|
+
this.#paginator = p;
|
|
86
|
+
this.items = p.items;
|
|
87
|
+
this.hasNextPage = p.hasNextPage;
|
|
88
|
+
// `aggregates` is only on the paginator type when the aggregate is non-empty,
|
|
89
|
+
// but remult returns `$count` for the empty case too - so read it through a cast.
|
|
90
|
+
this.aggregates = p.aggregates;
|
|
91
|
+
}
|
|
92
|
+
else if (this.#mode === 'one') {
|
|
93
|
+
const found = await this.#repo.findFirst(o.where, {
|
|
94
|
+
orderBy: o.orderBy,
|
|
95
|
+
include: o.include,
|
|
96
|
+
});
|
|
97
|
+
if (seq !== this.#seq)
|
|
98
|
+
return;
|
|
99
|
+
this.item = found ?? undefined;
|
|
100
|
+
this.items = found ? [found] : [];
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const items = await this.#repo.find({
|
|
104
|
+
where: o.where,
|
|
105
|
+
orderBy: o.orderBy,
|
|
106
|
+
limit: o.limit,
|
|
107
|
+
include: o.include,
|
|
108
|
+
});
|
|
109
|
+
if (seq !== this.#seq)
|
|
110
|
+
return;
|
|
111
|
+
this.items = items;
|
|
112
|
+
}
|
|
65
113
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
async query(options) {
|
|
70
|
-
this.loading = {
|
|
71
|
-
...this.loading,
|
|
72
|
-
fetching: true,
|
|
73
|
-
init: this.items === undefined,
|
|
74
|
-
};
|
|
75
|
-
// REMULT P1: add test for dynamic orderBy in remult
|
|
76
|
-
// Looks like only the default orderby of the entity is working
|
|
77
|
-
const { data: queryResult, error: queryResultError } = tryCatchSync(() => this.#repo.query({
|
|
78
|
-
pageSize: 2,
|
|
79
|
-
...this.#queryOptions,
|
|
80
|
-
...options,
|
|
81
|
-
// Yes, we always want to aggregate to get at least the $count!
|
|
82
|
-
// And empty object is giving us that
|
|
83
|
-
aggregate: {
|
|
84
|
-
...this.#queryOptions?.aggregate,
|
|
85
|
-
},
|
|
86
|
-
}));
|
|
87
|
-
if (queryResultError) {
|
|
88
|
-
this.globalError = queryResultError.message;
|
|
89
|
-
return this.loadingEnd();
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (seq === this.#seq)
|
|
116
|
+
this.error = e instanceof Error ? e.message : String(e);
|
|
90
117
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
finally {
|
|
119
|
+
if (seq === this.#seq) {
|
|
120
|
+
this.loading.init = false;
|
|
121
|
+
this.loading.fetching = false;
|
|
122
|
+
}
|
|
95
123
|
}
|
|
96
|
-
this.#paginator = paginator;
|
|
97
|
-
this.items = this.#paginator.items;
|
|
98
|
-
// @ts-expect-error - We know the structure will match due to how we define the types
|
|
99
|
-
this.aggregates = this.#paginator.aggregates;
|
|
100
|
-
this.hasNextPage = this.#paginator.hasNextPage && this.aggregates.$count > this.items.length;
|
|
101
|
-
return this.loadingEnd({
|
|
102
|
-
items: this.items,
|
|
103
|
-
aggregates: this.aggregates,
|
|
104
|
-
hasNextPage: this.hasNextPage,
|
|
105
|
-
});
|
|
106
124
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
/** Re-run the current query (load/paginate/one), back to the first page. */
|
|
126
|
+
async refresh() {
|
|
127
|
+
if (this.#mode === 'live')
|
|
128
|
+
throw new Error('FF_Repo: refresh() is not available in live mode');
|
|
129
|
+
await this.#load(this.#resolve(), ++this.#seq);
|
|
130
|
+
}
|
|
131
|
+
/** Load and append the next page (paginate mode). */
|
|
132
|
+
async more() {
|
|
133
|
+
if (this.#mode !== 'paginate')
|
|
134
|
+
throw new Error('FF_Repo: more() requires paginate mode');
|
|
135
|
+
if (!this.#paginator || this.loading.more || !this.hasNextPage)
|
|
136
|
+
return;
|
|
137
|
+
this.loading.more = true;
|
|
138
|
+
try {
|
|
139
|
+
const next = await this.#paginator.nextPage();
|
|
140
|
+
this.#paginator = next;
|
|
141
|
+
this.items = [...this.items, ...next.items];
|
|
142
|
+
this.hasNextPage = next.hasNextPage;
|
|
115
143
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
fetching: true,
|
|
119
|
-
more: true,
|
|
120
|
-
};
|
|
121
|
-
const { data: nextPage, error: nextPageError } = await tryCatch(this.#paginator.nextPage());
|
|
122
|
-
if (nextPageError) {
|
|
123
|
-
this.globalError = nextPageError.message;
|
|
124
|
-
return this.loadingEnd();
|
|
144
|
+
finally {
|
|
145
|
+
this.loading.more = false;
|
|
125
146
|
}
|
|
126
|
-
this.#paginator = nextPage;
|
|
127
|
-
this.items?.push(...nextPage.items);
|
|
128
|
-
this.hasNextPage = this.#paginator.hasNextPage && this.aggregates.$count > this.items.length;
|
|
129
|
-
return this.loadingEnd({
|
|
130
|
-
items: this.items,
|
|
131
|
-
aggregates: this.aggregates,
|
|
132
|
-
hasNextPage: this.hasNextPage,
|
|
133
|
-
});
|
|
134
147
|
}
|
|
135
148
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
149
|
+
* Run `fn` once, the first time a row is known (first non-null `first`).
|
|
150
|
+
* Never fires while the result is empty - so it's robust to liveQuery emitting
|
|
151
|
+
* an empty snapshot before the data lands. Nothing to seed from an empty result.
|
|
138
152
|
*/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
pageSize: currentCount,
|
|
150
|
-
aggregate: {
|
|
151
|
-
...this.#queryOptions?.aggregate,
|
|
152
|
-
},
|
|
153
|
-
}));
|
|
154
|
-
if (queryResultError) {
|
|
155
|
-
this.globalError = queryResultError.message;
|
|
156
|
-
return this.loadingEnd();
|
|
157
|
-
}
|
|
158
|
-
const { data: paginator, error: paginatorError } = await tryCatch(queryResult.paginator());
|
|
159
|
-
if (paginatorError) {
|
|
160
|
-
this.globalError = paginatorError.message;
|
|
161
|
-
return this.loadingEnd();
|
|
162
|
-
}
|
|
163
|
-
this.#paginator = paginator;
|
|
164
|
-
this.items = this.#paginator.items;
|
|
165
|
-
// @ts-expect-error - We know the structure will match due to how we define the types
|
|
166
|
-
this.aggregates = this.#paginator.aggregates;
|
|
167
|
-
this.hasNextPage = this.#paginator.hasNextPage && this.aggregates.$count > this.items.length;
|
|
168
|
-
return this.loadingEnd({
|
|
169
|
-
items: this.items,
|
|
170
|
-
aggregates: this.aggregates,
|
|
171
|
-
hasNextPage: this.hasNextPage,
|
|
153
|
+
firstOnce(fn) {
|
|
154
|
+
let done = false;
|
|
155
|
+
$effect(() => {
|
|
156
|
+
if (done)
|
|
157
|
+
return;
|
|
158
|
+
const latest = this.first;
|
|
159
|
+
if (latest == null)
|
|
160
|
+
return;
|
|
161
|
+
fn(latest);
|
|
162
|
+
done = true;
|
|
172
163
|
});
|
|
173
164
|
}
|
|
165
|
+
/** Reactive, bindable editor state seeded once from the latest row. */
|
|
166
|
+
draft(seed) {
|
|
167
|
+
const values = $state(seed(null));
|
|
168
|
+
this.firstOnce((latest) => Object.assign(values, seed(latest)));
|
|
169
|
+
return values;
|
|
170
|
+
}
|
|
171
|
+
/** Create a new unsaved entity into the `item` slot (for an edit form). */
|
|
174
172
|
create(...args) {
|
|
175
173
|
this.item = this.#repo.create(...args);
|
|
176
174
|
return this.item;
|
|
177
175
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
176
|
+
// Mutations: run `op`, then the post-write sync on SUCCESS only (a failed write
|
|
177
|
+
// leaves the result untouched). On failure we fill `error` AND re-throw - the
|
|
178
|
+
// caller still gets the rejection (not silenced); `error` is for a reactive
|
|
179
|
+
// UI that wants it. `finally` only flips `loading` (no `await`, which would
|
|
180
|
+
// mask the original error).
|
|
181
|
+
async #write(flag, op, after) {
|
|
182
|
+
this.loading[flag] = true;
|
|
183
|
+
this.error = undefined;
|
|
184
|
+
try {
|
|
185
|
+
const res = await op();
|
|
186
|
+
await after();
|
|
187
|
+
return res;
|
|
184
188
|
}
|
|
185
|
-
|
|
186
|
-
this.
|
|
189
|
+
catch (e) {
|
|
190
|
+
this.error = e instanceof Error ? e.message : String(e);
|
|
191
|
+
throw e;
|
|
187
192
|
}
|
|
188
|
-
|
|
189
|
-
this.
|
|
193
|
+
finally {
|
|
194
|
+
this.loading[flag] = false;
|
|
190
195
|
}
|
|
191
|
-
return this.loadingEnd();
|
|
192
196
|
}
|
|
197
|
+
insert(...args) {
|
|
198
|
+
return this.#write('saving', () => this.#repo.insert(...args), () => this.#resync());
|
|
199
|
+
}
|
|
200
|
+
update(...args) {
|
|
201
|
+
return this.#write('saving', () => this.#repo.update(...args), () => this.#resync());
|
|
202
|
+
}
|
|
203
|
+
/** Save. With no argument, saves the current `item` (e.g. a bound `one`/`create` form). */
|
|
204
|
+
save(...args) {
|
|
205
|
+
return this.#write('saving', () => this.#repo.save(...(args.length ? args : [this.#requireItem()])), () => this.#resync());
|
|
206
|
+
}
|
|
207
|
+
/** Delete. With no argument, deletes the current `item`. */
|
|
208
|
+
delete(...args) {
|
|
209
|
+
let target;
|
|
210
|
+
return this.#write('deleting', () => {
|
|
211
|
+
const a = (args.length ? args : [this.#requireItem()]);
|
|
212
|
+
target = a[0];
|
|
213
|
+
return this.#repo.delete(...a);
|
|
214
|
+
}, () => {
|
|
215
|
+
// live: liveQuery removes it. one: re-fetch (likely empty now).
|
|
216
|
+
// load/paginate: drop it locally (no refetch).
|
|
217
|
+
if (this.#mode === 'live')
|
|
218
|
+
return;
|
|
219
|
+
if (this.#mode === 'one')
|
|
220
|
+
return this.#resync();
|
|
221
|
+
this.#removeLocal(target);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** The current `item` (or throw) - backs the no-arg `save()`/`delete()` forms. */
|
|
225
|
+
#requireItem() {
|
|
226
|
+
if (this.item === undefined)
|
|
227
|
+
throw new Error('FF_Repo: no `item` to save/delete - pass an explicit argument, or load one first (`one` mode or `create()`).');
|
|
228
|
+
return this.item;
|
|
229
|
+
}
|
|
230
|
+
deleteMany(...args) {
|
|
231
|
+
return this.#write('deleting', () => this.#repo.deleteMany(...args), () => this.#resync());
|
|
232
|
+
}
|
|
233
|
+
// Client-side list reconcilers (no server I/O) - reflect a change you made
|
|
234
|
+
// elsewhere (e.g. via `.repo`) in the reactive `items`. `load`/`paginate` only;
|
|
235
|
+
// `listen` reconciles itself via the liveQuery. `add`/`remove` also adjust
|
|
236
|
+
// `aggregates.$count` (not the other aggregates). For authoritative state, call
|
|
237
|
+
// `refresh()` (it re-pulls and, for paginate, resets to the first page).
|
|
238
|
+
/** Insert into `items` at `top` (default) / `bottom` / an index (`-1` = last). +1 to `$count`. */
|
|
239
|
+
addItem(item, options) {
|
|
240
|
+
const at = options?.at ?? 'top';
|
|
241
|
+
const list = this.items;
|
|
242
|
+
const idx = at === 'top'
|
|
243
|
+
? 0
|
|
244
|
+
: at === 'bottom'
|
|
245
|
+
? list.length
|
|
246
|
+
: at < 0
|
|
247
|
+
? Math.max(0, list.length + at + 1)
|
|
248
|
+
: Math.min(at, list.length);
|
|
249
|
+
this.items = [...list.slice(0, idx), item, ...list.slice(idx)];
|
|
250
|
+
if (this.aggregates)
|
|
251
|
+
this.aggregates.$count += 1;
|
|
252
|
+
}
|
|
253
|
+
/** Replace the row whose id matches `item`'s id (no `$count` change). */
|
|
254
|
+
updateItem(item) {
|
|
255
|
+
const id = this.#repo.metadata.idMetadata.getId(item);
|
|
256
|
+
this.items = this.items.map((x) => (this.#repo.metadata.idMetadata.getId(x) === id ? item : x));
|
|
257
|
+
}
|
|
258
|
+
/** Drop the matching row (pass an id or the item). -1 to `$count`. */
|
|
259
|
+
removeItem(idOrItem) {
|
|
260
|
+
this.#removeLocal(idOrItem);
|
|
261
|
+
}
|
|
262
|
+
#removeLocal(idOrItem) {
|
|
263
|
+
const id = idOrItem != null && typeof idOrItem === 'object'
|
|
264
|
+
? this.#repo.metadata.idMetadata.getId(idOrItem)
|
|
265
|
+
: idOrItem;
|
|
266
|
+
this.items = this.items.filter((i) => this.#repo.metadata.idMetadata.getId(i) !== id);
|
|
267
|
+
if (this.aggregates)
|
|
268
|
+
this.aggregates.$count = Math.max(0, this.aggregates.$count - 1);
|
|
269
|
+
}
|
|
270
|
+
/** After insert/update (or a `one` delete) in a non-live mode, re-fetch keeping the current count. */
|
|
271
|
+
async #resync() {
|
|
272
|
+
if (this.#mode === 'live')
|
|
273
|
+
return;
|
|
274
|
+
const keepCount = this.#mode === 'paginate' ? this.items.length || undefined : undefined;
|
|
275
|
+
await this.#load(this.#resolve(), ++this.#seq, keepCount);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The entity's remult metadata - the single escape hatch for everything not on
|
|
279
|
+
* this handle: permissions (`apiInsertAllowed()`, `apiUpdateAllowed(item)`,
|
|
280
|
+
* `apiDeleteAllowed(item)`, `apiReadAllowed`), `fields`, `idMetadata`, `options`,
|
|
281
|
+
* `key`. Reflects the current `remult.user`.
|
|
282
|
+
*/
|
|
283
|
+
get meta() {
|
|
284
|
+
return this.#repo.metadata;
|
|
285
|
+
}
|
|
286
|
+
/** Escape hatch to the underlying repo (count, findId, upsert, projections, ...). */
|
|
287
|
+
get repo() {
|
|
288
|
+
return this.#repo;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
export function ffRepo(entity) {
|
|
292
|
+
const r = remultRepo(entity);
|
|
293
|
+
const builder = {
|
|
294
|
+
load(o) {
|
|
295
|
+
return new FF_RepoHandle(r, o, 'load');
|
|
296
|
+
},
|
|
297
|
+
listen(o) {
|
|
298
|
+
return new FF_RepoHandle(r, o, 'live');
|
|
299
|
+
},
|
|
300
|
+
paginate(o) {
|
|
301
|
+
return new FF_RepoHandle(r, o, 'paginate');
|
|
302
|
+
},
|
|
303
|
+
one(o) {
|
|
304
|
+
return new FF_RepoHandle(r, o, 'one');
|
|
305
|
+
},
|
|
306
|
+
get meta() {
|
|
307
|
+
return r.metadata;
|
|
308
|
+
},
|
|
309
|
+
repo: r,
|
|
310
|
+
};
|
|
311
|
+
return builder;
|
|
193
312
|
}
|
package/esm/svelte/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { ffRepo } from './FF_Repo.svelte.js';
|
|
2
|
+
export type { FF_Repo, FF_RepoOptions, FF_RepoLoading, FF_RepoLoad, FF_RepoLive, FF_RepoPaginate, FF_RepoOne, FF_RepoBuilder, AggregateOptions, QueryOptionsHelper, } from './FF_Repo.svelte.js';
|
|
3
|
+
export { infiniteScroll } from './infiniteScroll.js';
|
|
4
|
+
export type { InfiniteScrollOptions } from './infiniteScroll.js';
|
|
2
5
|
export { SP } from './class/SP.svelte';
|
|
3
6
|
export type { ParamDefinition } from './class/SP.svelte';
|
|
4
7
|
export { initRemultSvelteReactivity } from './initRemultSvelteReactivity';
|
package/esm/svelte/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { ffRepo } from './FF_Repo.svelte.js';
|
|
2
|
+
export { infiniteScroll } from './infiniteScroll.js';
|
|
2
3
|
export { SP } from './class/SP.svelte';
|
|
3
4
|
export { initRemultSvelteReactivity } from './initRemultSvelteReactivity';
|
|
4
5
|
export { default as Icon } from './ui/Icon.svelte';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
export type InfiniteScrollOptions = {
|
|
3
|
+
/** Are there more pages to load? */
|
|
4
|
+
hasMore: () => boolean;
|
|
5
|
+
/** Is a load already in flight? */
|
|
6
|
+
loading: () => boolean;
|
|
7
|
+
/** Load the next page. */
|
|
8
|
+
onMore: () => void;
|
|
9
|
+
/** Preload distance below the scroll viewport, in px (default 1200). */
|
|
10
|
+
rootMarginPx?: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Infinite-scroll attachment: put it on a bottom sentinel element and it calls
|
|
14
|
+
* `onMore()` when the sentinel nears the scroll container. The observer is rooted
|
|
15
|
+
* on the nearest scrollable ancestor, so it works when the app scrolls inside an
|
|
16
|
+
* inner div (not the window), and a `rootMarginPx` margin streams the next page in
|
|
17
|
+
* just before the sentinel shows. Guarded by `hasMore()`/`loading()`. Because it
|
|
18
|
+
* observes geometry it never misfires on mount: a full first page leaves the
|
|
19
|
+
* sentinel far below the root.
|
|
20
|
+
*
|
|
21
|
+
* Pairs with `ffRepo(E).paginate(...)`:
|
|
22
|
+
*
|
|
23
|
+
* ```svelte
|
|
24
|
+
* <div {@attach infiniteScroll({
|
|
25
|
+
* hasMore: () => r.hasNextPage,
|
|
26
|
+
* loading: () => r.loading.more,
|
|
27
|
+
* onMore: () => r.more(),
|
|
28
|
+
* })}></div>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function infiniteScroll(opts: InfiniteScrollOptions): Attachment;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Nearest scrollable ancestor, or null (the document/window scrolls). */
|
|
2
|
+
function scrollParent(el) {
|
|
3
|
+
let p = el.parentElement;
|
|
4
|
+
while (p && p !== document.body) {
|
|
5
|
+
const oy = getComputedStyle(p).overflowY;
|
|
6
|
+
if (oy === 'auto' || oy === 'scroll')
|
|
7
|
+
return p;
|
|
8
|
+
p = p.parentElement;
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Infinite-scroll attachment: put it on a bottom sentinel element and it calls
|
|
14
|
+
* `onMore()` when the sentinel nears the scroll container. The observer is rooted
|
|
15
|
+
* on the nearest scrollable ancestor, so it works when the app scrolls inside an
|
|
16
|
+
* inner div (not the window), and a `rootMarginPx` margin streams the next page in
|
|
17
|
+
* just before the sentinel shows. Guarded by `hasMore()`/`loading()`. Because it
|
|
18
|
+
* observes geometry it never misfires on mount: a full first page leaves the
|
|
19
|
+
* sentinel far below the root.
|
|
20
|
+
*
|
|
21
|
+
* Pairs with `ffRepo(E).paginate(...)`:
|
|
22
|
+
*
|
|
23
|
+
* ```svelte
|
|
24
|
+
* <div {@attach infiniteScroll({
|
|
25
|
+
* hasMore: () => r.hasNextPage,
|
|
26
|
+
* loading: () => r.loading.more,
|
|
27
|
+
* onMore: () => r.more(),
|
|
28
|
+
* })}></div>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function infiniteScroll(opts) {
|
|
32
|
+
return (node) => {
|
|
33
|
+
const io = new IntersectionObserver((entries) => {
|
|
34
|
+
if (entries[0]?.isIntersecting && opts.hasMore() && !opts.loading())
|
|
35
|
+
opts.onMore();
|
|
36
|
+
}, { root: scrollParent(node), rootMargin: `${opts.rootMarginPx ?? 1200}px 0px` });
|
|
37
|
+
io.observe(node);
|
|
38
|
+
return () => io.disconnect();
|
|
39
|
+
};
|
|
40
|
+
}
|