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 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): 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): 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: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_One<Entity, O>;
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
  };
@@ -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
- const found = await this.#repo.findFirst(o.where, {
104
- orderBy: o.orderBy,
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;
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firstly",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Firstly, an opinionated Remult setup!",
6
6
  "funding": "https://github.com/sponsors/jycouet",