firstly 0.6.0 → 0.6.1
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 +10 -0
- package/esm/svelte/ff.svelte.d.ts +71 -8
- package/esm/svelte/ff.svelte.js +85 -5
- package/esm/svelte/index.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.6.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#294](https://github.com/jycouet/firstly/pull/294) [`7e8c2af`](https://github.com/jycouet/firstly/commit/7e8c2afa7806cf3383d64b048e8f3b9b5b19d237) Thanks [@jycouet](https://github.com/jycouet)! - ff: add `onNew` and `onIssue` lifecycle hooks to the reactive handle (and make `onFirst` chainable)
|
|
8
|
+
- **`onItem(record => …)`** (`one` mode) / **`onItems(items => …)`** (list modes) - run after each fetch with the loaded data, mirroring the handle's `.item` / `.items` (replaces the old `storeList`/`storeItem` `onNewData`). `onItem` fires only when a row is found - a not-found goes to `onIssue`. `onFirst` stays the once-only seed.
|
|
9
|
+
- **`onIssue(issue => …)`** - runs when a read doesn't yield the expected data. `issue` is `{ kind: 'notFound' | 'forbidden' | 'error', status?, message? }`; switch on `kind` to react (e.g. redirect). A `one` query that resolves with no row reports `{ kind: 'notFound', status: 404 }`; a rejected read reports `forbidden` (403) or `error`.
|
|
10
|
+
- All three hooks are now chainable: `ff(E).one(getter).onIssue(…).onNew(…).onFirst(…)`.
|
|
11
|
+
- **`ff(E).one()` accepts `{ id }` or `{ where }`** (mutually exclusive - a type error if both). `{ id }` loads by primary key via `findId` (no `_sort`/`_limit` on a unique lookup, and dedups with other findId callers); `{ where }` loads via `findFirst` (with optional `orderBy`). `{ id }` re-runs reactively when the id changes.
|
|
12
|
+
|
|
3
13
|
## 0.6.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
|
@@ -36,8 +36,12 @@ export type AggregateOptions<Entity> = Omit<GroupByOptions<Entity, never, Numeri
|
|
|
36
36
|
export type QueryOptionsHelper<Entity> = QueryOptions<Entity> & {
|
|
37
37
|
aggregate?: AggregateOptions<Entity>;
|
|
38
38
|
};
|
|
39
|
+
/** The primary-key type of an entity (single value, or a composite-id object) - from remult's `findId`. */
|
|
40
|
+
export type FF_Id<Entity> = Parameters<Repository<Entity>['findId']>[0];
|
|
39
41
|
export type FF_RepoOptions<Entity> = {
|
|
40
42
|
where?: EntityFilter<Entity>;
|
|
43
|
+
/** `one` mode: load by primary key via `findId` (no sort/limit). Mutually exclusive with `where`. */
|
|
44
|
+
id?: FF_Id<Entity>;
|
|
41
45
|
orderBy?: EntityOrderBy<Entity>;
|
|
42
46
|
/** paginate: rows per page (default 25). */
|
|
43
47
|
pageSize?: number;
|
|
@@ -49,6 +53,23 @@ export type FF_RepoOptions<Entity> = {
|
|
|
49
53
|
/** Aggregations to compute alongside the page (paginate mode only). `$count` is always returned. */
|
|
50
54
|
aggregate?: AggregateOptions<Entity>;
|
|
51
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* Options for `ff(E).one(...)`: load a single record either by primary key
|
|
58
|
+
* (`id` -> `findId`, no sort/limit) OR by filter (`where` -> `findFirst`, with `orderBy`).
|
|
59
|
+
* Exactly one of `id` / `where` - setting both is a type error.
|
|
60
|
+
*/
|
|
61
|
+
export type FF_OneOptions<Entity> = {
|
|
62
|
+
include?: MembersToInclude<Entity>;
|
|
63
|
+
enabled?: boolean;
|
|
64
|
+
} & ({
|
|
65
|
+
id: FF_Id<Entity>;
|
|
66
|
+
where?: never;
|
|
67
|
+
orderBy?: never;
|
|
68
|
+
} | {
|
|
69
|
+
id?: never;
|
|
70
|
+
where?: EntityFilter<Entity>;
|
|
71
|
+
orderBy?: EntityOrderBy<Entity>;
|
|
72
|
+
});
|
|
52
73
|
type Getter<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = () => O;
|
|
53
74
|
/** `$count` is always present; richer keys appear only for the requested `aggregate`. */
|
|
54
75
|
type EmptyAggregateResult = {
|
|
@@ -75,6 +96,20 @@ export type FF_RepoLoading = {
|
|
|
75
96
|
saving: boolean;
|
|
76
97
|
deleting: boolean;
|
|
77
98
|
};
|
|
99
|
+
/**
|
|
100
|
+
* What `onIssue` reports when a read doesn't yield the expected data:
|
|
101
|
+
* - `notFound` - a `one` query resolved with no row (status 404)
|
|
102
|
+
* - `forbidden` - the API rejected the read (status 403, e.g. permissions)
|
|
103
|
+
* - `error` - any other failure (network, server, ...)
|
|
104
|
+
*
|
|
105
|
+
* `status`/`message` are best-effort, read off the underlying error when present.
|
|
106
|
+
* Switch on `kind` to react (e.g. redirect on `notFound`/`forbidden`).
|
|
107
|
+
*/
|
|
108
|
+
export type FF_Issue = {
|
|
109
|
+
kind: 'notFound' | 'forbidden' | 'error';
|
|
110
|
+
status?: number;
|
|
111
|
+
message?: string;
|
|
112
|
+
};
|
|
78
113
|
type Mode = 'live' | 'load' | 'paginate' | 'one';
|
|
79
114
|
/**
|
|
80
115
|
* The reactive handle implementation (internal). `ff().one()` returns an Omit'd view of this
|
|
@@ -118,7 +153,31 @@ declare class FF_RepoHandle<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOp
|
|
|
118
153
|
* list.onFirst((latest) => (draft.title = latest.title)) // seed once, then edit freely
|
|
119
154
|
* ```
|
|
120
155
|
*/
|
|
121
|
-
onFirst(fn: (latest: Entity) => void):
|
|
156
|
+
onFirst(fn: (latest: Entity) => void): this;
|
|
157
|
+
/**
|
|
158
|
+
* `one` mode only. Run `fn` with the record after EVERY fetch that finds one (a
|
|
159
|
+
* not-found goes to `onIssue` instead). Mirrors the handle's `.item`. Unlike
|
|
160
|
+
* `onFirst` (once, on the first row), this fires on each load / refresh. Chainable.
|
|
161
|
+
*/
|
|
162
|
+
onItem(fn: (item: Entity) => void): this;
|
|
163
|
+
/**
|
|
164
|
+
* List modes only. Run `fn` with the fresh `items` after EVERY read (mimics the old
|
|
165
|
+
* `storeList` `onNewData`). Fires on each load / refresh / paginate page / live tick.
|
|
166
|
+
* Mirrors the handle's `.items`. Chainable.
|
|
167
|
+
*/
|
|
168
|
+
onItems(fn: (items: Entity[]) => void): this;
|
|
169
|
+
/**
|
|
170
|
+
* Run `fn` when a read doesn't yield the expected data: a `one` query with no row
|
|
171
|
+
* (`notFound`), a rejected read (`forbidden`, 403), or any other failure (`error`).
|
|
172
|
+
* The arg is an `FF_Issue` ({@link FF_Issue}) - switch on `issue.kind` to react,
|
|
173
|
+
* e.g. redirect on not-found. Chainable.
|
|
174
|
+
*
|
|
175
|
+
* ```svelte
|
|
176
|
+
* const site = ff(Site).one(() => ({ where: { id } }))
|
|
177
|
+
* .onIssue((i) => { if (i.kind === 'notFound') goto('/app/sites') })
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
onIssue(fn: (issue: FF_Issue) => void): this;
|
|
122
181
|
/** Create a new unsaved entity into the `item` slot (for an edit form). */
|
|
123
182
|
create(...args: Parameters<Repository<Entity>['create']>): Entity;
|
|
124
183
|
/** Save the current `item` (from `one` / `create()`). To save a specific row, use remult `repo(E).save(row)`. */
|
|
@@ -168,13 +227,13 @@ declare class FF_RepoHandle<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOp
|
|
|
168
227
|
get repo(): Repository<Entity>;
|
|
169
228
|
}
|
|
170
229
|
/** load: one-shot list (`refresh()` to re-run) - a read+reconcile view. No paging/aggregates, and no `item`/`save`/`delete`/`create` (edit via `one`, write via remult `repo(E)`). */
|
|
171
|
-
export type FF_RepoLoad<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'item' | 'save' | 'delete' | 'create' | 'syncs'>;
|
|
230
|
+
export type FF_RepoLoad<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'item' | 'save' | 'delete' | 'create' | 'syncs' | 'onItem'>;
|
|
172
231
|
/** live: reactive subscription, auto-updates - a read view. No refresh/paging/aggregates/reconcilers (the liveQuery does it), and no `item`/`save`/`delete`/`create`. */
|
|
173
|
-
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' | 'item' | 'save' | 'delete' | 'create' | 'reconcile' | 'syncs'>;
|
|
232
|
+
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' | 'item' | 'save' | 'delete' | 'create' | 'reconcile' | 'syncs' | 'onItem'>;
|
|
174
233
|
/** paginate: `more()` / `hasNextPage` / `aggregates` - a read+reconcile view. No `onFirst` (paged ≠ latest), and no `item`/`save`/`delete`/`create`. */
|
|
175
|
-
export type FF_RepoPaginate<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'onFirst' | 'item' | 'save' | 'delete' | 'create' | 'syncs'>;
|
|
234
|
+
export type FF_RepoPaginate<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'onFirst' | 'onItem' | 'item' | 'save' | 'delete' | 'create' | 'syncs'>;
|
|
176
235
|
/** one: a single reactive record in `item`. No paging / aggregates / list reconcilers. */
|
|
177
|
-
export type FF_RepoOne<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem' | 'reconcile'>;
|
|
236
|
+
export type FF_RepoOne<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem' | 'reconcile' | 'onItems'>;
|
|
178
237
|
/** The list strategy backing `many`. Maps `listen` → live mode internally. */
|
|
179
238
|
export type ManyStrategy = 'load' | 'listen' | 'paginate';
|
|
180
239
|
/**
|
|
@@ -267,7 +326,11 @@ declare class FF_ManyHandle<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOp
|
|
|
267
326
|
* `$bindable` the user then mutates), not the draft; for pure display prefer
|
|
268
327
|
* `$derived(handle.items[0])`. Delegates to the list handle (see `onFirst` there).
|
|
269
328
|
*/
|
|
270
|
-
onFirst(fn: (latest: Entity) => void):
|
|
329
|
+
onFirst(fn: (latest: Entity) => void): this;
|
|
330
|
+
/** Run `fn` after every successful list read, with the fresh `items`. Delegates to the list handle. Chainable. */
|
|
331
|
+
onItems(fn: (items: Entity[]) => void): this;
|
|
332
|
+
/** Run `fn` when a list read fails (`forbidden`/`error`). Delegates to the list handle. Chainable. */
|
|
333
|
+
onIssue(fn: (issue: FF_Issue) => void): this;
|
|
271
334
|
get meta(): EntityMetadata<Entity>;
|
|
272
335
|
get repo(): Repository<Entity>;
|
|
273
336
|
}
|
|
@@ -280,8 +343,8 @@ export type FF_One<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Ent
|
|
|
280
343
|
export type FF_Builder<Entity> = {
|
|
281
344
|
/** A crud composite (list + editing draft + writes). `strategy` picks the fetch (default `paginate`). */
|
|
282
345
|
many: <O extends FF_RepoOptions<Entity>, S extends ManyStrategy = 'paginate'>(opts: StrictGetter<Entity, O>, strategy?: S) => FF_Many<Entity, S, O>;
|
|
283
|
-
/** A single reactive record bound to `item
|
|
284
|
-
one:
|
|
346
|
+
/** A single reactive record bound to `item` - by primary key (`{ id }` -> findId) or filter (`{ where }` -> findFirst). */
|
|
347
|
+
one: (opts: () => FF_OneOptions<Entity>) => FF_One<Entity>;
|
|
285
348
|
/** The entity's remult metadata (captions, permissions, fields). */
|
|
286
349
|
readonly meta: EntityMetadata<Entity>;
|
|
287
350
|
};
|
package/esm/svelte/ff.svelte.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { repo as remultRepo, } from 'remult';
|
|
2
2
|
import { dialog } from './dialog.svelte.js';
|
|
3
|
+
/** Classify a thrown read error into an {@link FF_Issue} (best-effort status sniffing). */
|
|
4
|
+
function toIssue(e) {
|
|
5
|
+
const err = e;
|
|
6
|
+
const status = err?.httpStatusCode ?? err?.status;
|
|
7
|
+
const message = e instanceof Error ? e.message : typeof e === 'string' ? e : err?.message;
|
|
8
|
+
const kind = status === 403 ? 'forbidden' : status === 404 ? 'notFound' : 'error';
|
|
9
|
+
return { kind, status, message };
|
|
10
|
+
}
|
|
3
11
|
/**
|
|
4
12
|
* The reactive handle implementation (internal). `ff().one()` returns an Omit'd view of this
|
|
5
13
|
* (`FF_One`); `ff().many()` wraps a list handle + a `.syncs`-linked one in `FF_ManyHandle`
|
|
@@ -13,6 +21,9 @@ class FF_RepoHandle {
|
|
|
13
21
|
#paginator;
|
|
14
22
|
#seq = 0;
|
|
15
23
|
#syncTargets = [];
|
|
24
|
+
#onItemCbs = [];
|
|
25
|
+
#onItemsCbs = [];
|
|
26
|
+
#onIssueCbs = [];
|
|
16
27
|
items = $state([]);
|
|
17
28
|
/** Single-record slot: the loaded row in `one` mode, or a create/edit draft (see `create`). */
|
|
18
29
|
item = $state(undefined);
|
|
@@ -56,10 +67,12 @@ class FF_RepoHandle {
|
|
|
56
67
|
next: (info) => {
|
|
57
68
|
this.items = info.items;
|
|
58
69
|
this.loading.init = false;
|
|
70
|
+
this.#fireItems();
|
|
59
71
|
},
|
|
60
72
|
error: (e) => {
|
|
61
73
|
this.error = e instanceof Error ? e.message : String(e);
|
|
62
74
|
this.loading.init = false;
|
|
75
|
+
this.#fireIssue(toIssue(e));
|
|
63
76
|
},
|
|
64
77
|
});
|
|
65
78
|
return () => unsub();
|
|
@@ -98,16 +111,23 @@ class FF_RepoHandle {
|
|
|
98
111
|
// `aggregates` is only on the paginator type when the aggregate is non-empty,
|
|
99
112
|
// but remult returns `$count` for the empty case too - so read it through a cast.
|
|
100
113
|
this.aggregates = p.aggregates;
|
|
114
|
+
this.#fireItems();
|
|
101
115
|
}
|
|
102
116
|
else if (this.#mode === 'one') {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
include: o.include
|
|
106
|
-
|
|
117
|
+
// `id` -> findId (by primary key, no sort/limit); otherwise `where` -> findFirst.
|
|
118
|
+
const found = o.id != null
|
|
119
|
+
? await this.#repo.findId(o.id, { include: o.include })
|
|
120
|
+
: await this.#repo.findFirst(o.where, { orderBy: o.orderBy, include: o.include });
|
|
107
121
|
if (seq !== this.#seq)
|
|
108
122
|
return;
|
|
109
123
|
this.item = found ?? undefined;
|
|
110
124
|
this.items = found ? [found] : [];
|
|
125
|
+
// Found -> onItem; not found -> onIssue (a row was deleted, the id is wrong, or a
|
|
126
|
+
// prefilter hid it) so callers can redirect/404 instead of sitting on an empty record.
|
|
127
|
+
if (found)
|
|
128
|
+
this.#fireItem(found);
|
|
129
|
+
else
|
|
130
|
+
this.#fireIssue({ kind: 'notFound', status: 404 });
|
|
111
131
|
}
|
|
112
132
|
else {
|
|
113
133
|
const items = await this.#repo.find({
|
|
@@ -119,11 +139,14 @@ class FF_RepoHandle {
|
|
|
119
139
|
if (seq !== this.#seq)
|
|
120
140
|
return;
|
|
121
141
|
this.items = items;
|
|
142
|
+
this.#fireItems();
|
|
122
143
|
}
|
|
123
144
|
}
|
|
124
145
|
catch (e) {
|
|
125
|
-
if (seq === this.#seq)
|
|
146
|
+
if (seq === this.#seq) {
|
|
126
147
|
this.error = e instanceof Error ? e.message : String(e);
|
|
148
|
+
this.#fireIssue(toIssue(e));
|
|
149
|
+
}
|
|
127
150
|
}
|
|
128
151
|
finally {
|
|
129
152
|
if (seq === this.#seq) {
|
|
@@ -189,6 +212,52 @@ class FF_RepoHandle {
|
|
|
189
212
|
fn(latest);
|
|
190
213
|
done = true;
|
|
191
214
|
});
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
#fireItem(item) {
|
|
218
|
+
for (const fn of this.#onItemCbs)
|
|
219
|
+
fn(item);
|
|
220
|
+
}
|
|
221
|
+
#fireItems() {
|
|
222
|
+
for (const fn of this.#onItemsCbs)
|
|
223
|
+
fn(this.items);
|
|
224
|
+
}
|
|
225
|
+
#fireIssue(issue) {
|
|
226
|
+
for (const fn of this.#onIssueCbs)
|
|
227
|
+
fn(issue);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* `one` mode only. Run `fn` with the record after EVERY fetch that finds one (a
|
|
231
|
+
* not-found goes to `onIssue` instead). Mirrors the handle's `.item`. Unlike
|
|
232
|
+
* `onFirst` (once, on the first row), this fires on each load / refresh. Chainable.
|
|
233
|
+
*/
|
|
234
|
+
onItem(fn) {
|
|
235
|
+
this.#onItemCbs.push(fn);
|
|
236
|
+
return this;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* List modes only. Run `fn` with the fresh `items` after EVERY read (mimics the old
|
|
240
|
+
* `storeList` `onNewData`). Fires on each load / refresh / paginate page / live tick.
|
|
241
|
+
* Mirrors the handle's `.items`. Chainable.
|
|
242
|
+
*/
|
|
243
|
+
onItems(fn) {
|
|
244
|
+
this.#onItemsCbs.push(fn);
|
|
245
|
+
return this;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Run `fn` when a read doesn't yield the expected data: a `one` query with no row
|
|
249
|
+
* (`notFound`), a rejected read (`forbidden`, 403), or any other failure (`error`).
|
|
250
|
+
* The arg is an `FF_Issue` ({@link FF_Issue}) - switch on `issue.kind` to react,
|
|
251
|
+
* e.g. redirect on not-found. Chainable.
|
|
252
|
+
*
|
|
253
|
+
* ```svelte
|
|
254
|
+
* const site = ff(Site).one(() => ({ where: { id } }))
|
|
255
|
+
* .onIssue((i) => { if (i.kind === 'notFound') goto('/app/sites') })
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
onIssue(fn) {
|
|
259
|
+
this.#onIssueCbs.push(fn);
|
|
260
|
+
return this;
|
|
192
261
|
}
|
|
193
262
|
/** Create a new unsaved entity into the `item` slot (for an edit form). */
|
|
194
263
|
create(...args) {
|
|
@@ -570,6 +639,17 @@ class FF_ManyHandle {
|
|
|
570
639
|
*/
|
|
571
640
|
onFirst(fn) {
|
|
572
641
|
this.#list.onFirst(fn);
|
|
642
|
+
return this;
|
|
643
|
+
}
|
|
644
|
+
/** Run `fn` after every successful list read, with the fresh `items`. Delegates to the list handle. Chainable. */
|
|
645
|
+
onItems(fn) {
|
|
646
|
+
this.#list.onItems(fn);
|
|
647
|
+
return this;
|
|
648
|
+
}
|
|
649
|
+
/** Run `fn` when a list read fails (`forbidden`/`error`). Delegates to the list handle. Chainable. */
|
|
650
|
+
onIssue(fn) {
|
|
651
|
+
this.#list.onIssue(fn);
|
|
652
|
+
return this;
|
|
573
653
|
}
|
|
574
654
|
get meta() {
|
|
575
655
|
return this.#repo.metadata;
|
package/esm/svelte/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { ff } from './ff.svelte.js';
|
|
2
|
-
export type { FF_Many, FF_One, FF_Builder, FF_RepoOptions, FF_RepoLoading, ManyStrategy, AggregateOptions, QueryOptionsHelper, } from './ff.svelte.js';
|
|
2
|
+
export type { FF_Many, FF_One, FF_Builder, FF_RepoOptions, FF_OneOptions, FF_RepoLoading, FF_Issue, ManyStrategy, AggregateOptions, QueryOptionsHelper, } from './ff.svelte.js';
|
|
3
3
|
export { infiniteScroll } from './infiniteScroll.js';
|
|
4
4
|
export type { InfiniteScrollOptions } from './infiniteScroll.js';
|
|
5
5
|
export { dialog, ffAutofocus, resolveMessage } from './dialog.svelte.js';
|