firstly 0.6.0 → 0.6.2

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,33 @@
1
1
  # firstly
2
2
 
3
+ ## 0.6.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#296](https://github.com/jycouet/firstly/pull/296) [`ae7d8ec`](https://github.com/jycouet/firstly/commit/ae7d8ec532e677d42e1e5730c1ef58a29bb32062) Thanks [@jycouet](https://github.com/jycouet)! - fix(svelte): portal `FF_DialogManager` panels to `<body>`
8
+
9
+ Dialog/confirm/prompt panels rendered wherever `<FF_DialogManager>` sat in the
10
+ layout - inside the app root that the manager marks `inert` to trap focus. When
11
+ the `inert` effect won the race against the panel's autofocus, the whole panel
12
+ stopped receiving pointer events (real clicks died; `elementFromPoint` returned
13
+ `<body>`; AT saw it as "ignored"), while synthetic `.click()` still worked - so
14
+ it looked fine in tests but was dead under a real mouse.
15
+
16
+ Panels are now portaled to `<body>` (true siblings of the app root, matching the
17
+ existing design comment), so inerting the root never touches them. The
18
+ now-obsolete `root.contains(activeElement)` race-guard is dropped, so the
19
+ background is reliably inerted again.
20
+
21
+ ## 0.6.1
22
+
23
+ ### Patch Changes
24
+
25
+ - [#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)
26
+ - **`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.
27
+ - **`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`.
28
+ - All three hooks are now chainable: `ff(E).one(getter).onIssue(…).onNew(…).onFirst(…)`.
29
+ - **`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.
30
+
3
31
  ## 0.6.0
4
32
 
5
33
  ### Minor Changes
@@ -77,19 +77,33 @@
77
77
  }
78
78
  })
79
79
 
80
+ // Move a dialog panel out to `<body>` so it becomes a true sibling of the app
81
+ // root, NOT a descendant of it. The inert effect below marks the app root
82
+ // `inert`; without this portal the panels render inside that root (wherever
83
+ // <FF_DialogManager> is mounted in the layout) and get disabled too - the
84
+ // panel stops receiving pointer events the moment `inert` wins the race
85
+ // against the panel's autofocus, so real clicks (and AT) silently die. The
86
+ // `portal` runs before the inner `ffAutofocus`, so focus still lands correctly.
87
+ function portal(node: HTMLElement) {
88
+ document.body.appendChild(node)
89
+ return {
90
+ destroy() {
91
+ node.remove()
92
+ },
93
+ }
94
+ }
95
+
80
96
  // Mark the app root `inert` (+ aria-hidden) while any dialog is open, so the background can't
81
- // be tabbed into or read by AT. The dialog panels render at the document body level (a sibling
82
- // of the app root), so inerting the root never touches the panels. SSR-safe.
97
+ // be tabbed into or read by AT. The dialog panels are portaled to <body> (a sibling of the app
98
+ // root), so inerting the root never touches them - we can apply it unconditionally (no
99
+ // activeElement race-guard, which previously let the background stay live). SSR-safe.
83
100
  $effect(() => {
84
101
  if (typeof document === 'undefined' || total === 0) return
85
102
  const root = document.querySelector<HTMLElement>(
86
103
  '[data-sveltekit-root], #svelte, body > div:first-child',
87
104
  )
88
- if (!root || root.contains(document.activeElement)) {
89
- // Fallback: no identifiable single root, or the dialog itself lives under it - skip
90
- // inert (the per-panel focus trap still contains keyboard navigation).
91
- return
92
- }
105
+ // No identifiable single root: skip (the per-panel focus trap still contains keyboard nav).
106
+ if (!root) return
93
107
  root.setAttribute('inert', '')
94
108
  root.setAttribute('aria-hidden', 'true')
95
109
  return () => {
@@ -112,52 +126,58 @@
112
126
  {@render d.render.body(close)}
113
127
  {/if}
114
128
  {/snippet}
115
- {@render (shell ?? cfg.dialog.shell ?? defaultShell)({
116
- id: d.id,
117
- body: itemBody,
118
- close: (r) => dialog._close(d.id, r),
119
- dismiss: () => dialog.requestClose(d.id),
120
- dismissible: d.options.dismissible,
121
- width: d.options.width,
122
- isTop: d.id === topId,
123
- })}
129
+ <div use:portal>
130
+ {@render (shell ?? cfg.dialog.shell ?? defaultShell)({
131
+ id: d.id,
132
+ body: itemBody,
133
+ close: (r) => dialog._close(d.id, r),
134
+ dismiss: () => dialog.requestClose(d.id),
135
+ dismissible: d.options.dismissible,
136
+ width: d.options.width,
137
+ isTop: d.id === topId,
138
+ })}
139
+ </div>
124
140
  {/each}
125
141
 
126
142
  {#each dialog.confirmList as c (c.id)}
127
- {@render (confirm ?? cfg.dialog.confirm ?? defaultConfirm)({
128
- id: c.id,
129
- message: resolveMessage(c.message),
130
- title: c.title === undefined ? undefined : resolveMessage(c.title),
131
- confirmLabel: resolveMessage(c.confirmLabel ?? cfg.messages.confirm),
132
- cancelLabel: resolveMessage(c.cancelLabel ?? cfg.messages.cancel),
133
- danger: c.danger,
134
- confirm: () => dialog._resolveConfirm(c.id, true),
135
- cancel: () => dialog._resolveConfirm(c.id, false),
136
- isTop: c.id === topId,
137
- })}
143
+ <div use:portal>
144
+ {@render (confirm ?? cfg.dialog.confirm ?? defaultConfirm)({
145
+ id: c.id,
146
+ message: resolveMessage(c.message),
147
+ title: c.title === undefined ? undefined : resolveMessage(c.title),
148
+ confirmLabel: resolveMessage(c.confirmLabel ?? cfg.messages.confirm),
149
+ cancelLabel: resolveMessage(c.cancelLabel ?? cfg.messages.cancel),
150
+ danger: c.danger,
151
+ confirm: () => dialog._resolveConfirm(c.id, true),
152
+ cancel: () => dialog._resolveConfirm(c.id, false),
153
+ isTop: c.id === topId,
154
+ })}
155
+ </div>
138
156
  {/each}
139
157
 
140
158
  {#each dialog.promptList as p (p.id)}
141
159
  {@const promptUi = prompt ?? cfg.dialog.prompt}
142
- {#if promptUi}
143
- {@render promptUi({
144
- id: p.id,
145
- title: p.title === undefined ? undefined : resolveMessage(p.title),
146
- label: p.label === undefined ? undefined : resolveMessage(p.label),
147
- placeholder: p.placeholder,
148
- initial: p.initial,
149
- confirmLabel: resolveMessage(p.confirmLabel ?? cfg.messages.ok),
150
- cancelLabel: resolveMessage(p.cancelLabel ?? cfg.messages.cancel),
151
- submit: (value) => dialog._resolvePrompt(p.id, value),
152
- cancel: () => dialog._resolvePrompt(p.id, null),
153
- })}
154
- {:else}
155
- <FF_PromptDefault
156
- item={p}
157
- onsubmit={(value) => dialog._resolvePrompt(p.id, value)}
158
- oncancel={() => dialog._resolvePrompt(p.id, null)}
159
- />
160
- {/if}
160
+ <div use:portal>
161
+ {#if promptUi}
162
+ {@render promptUi({
163
+ id: p.id,
164
+ title: p.title === undefined ? undefined : resolveMessage(p.title),
165
+ label: p.label === undefined ? undefined : resolveMessage(p.label),
166
+ placeholder: p.placeholder,
167
+ initial: p.initial,
168
+ confirmLabel: resolveMessage(p.confirmLabel ?? cfg.messages.ok),
169
+ cancelLabel: resolveMessage(p.cancelLabel ?? cfg.messages.cancel),
170
+ submit: (value) => dialog._resolvePrompt(p.id, value),
171
+ cancel: () => dialog._resolvePrompt(p.id, null),
172
+ })}
173
+ {:else}
174
+ <FF_PromptDefault
175
+ item={p}
176
+ onsubmit={(value) => dialog._resolvePrompt(p.id, value)}
177
+ oncancel={() => dialog._resolvePrompt(p.id, null)}
178
+ />
179
+ {/if}
180
+ </div>
161
181
  {/each}
162
182
 
163
183
  <!-- Built-in defaults: usable with zero config and theme-adaptive via semantic tokens
@@ -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.2",
4
4
  "type": "module",
5
5
  "description": "Firstly, an opinionated Remult setup!",
6
6
  "funding": "https://github.com/sponsors/jycouet",