firstly 0.7.2 → 0.8.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # firstly
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#318](https://github.com/jycouet/firstly/pull/318) [`89cb7ee`](https://github.com/jycouet/firstly/commit/89cb7ee93ee783283156c0adeda8b642233f6876) Thanks [@jycouet](https://github.com/jycouet)! - feat: `withShortTermCache` http middleware (dedupes identical read requests within a TTL) and `stackSubscriptionClient` + `withTabSharing` (one shared SSE connection across tabs via `navigator.locks`, with liveQuery resync on leader handoff and reconnect)
8
+
9
+ - [#322](https://github.com/jycouet/firstly/pull/322) [`fdcc2b7`](https://github.com/jycouet/firstly/commit/fdcc2b7249d737b9bdb08de6feeddc37ca86a952) Thanks [@jycouet](https://github.com/jycouet)! - svelte: add `stackHandleClientError(...middlewares)` and a `withStaleDeployReload()` middleware for `hooks.client.js`, mirroring `stackHttpClient`. Stack your own error handlers alongside stale-deploy recovery: `withStaleDeployReload` hard-reloads once on a chunk-load failure (time-boxed guard, no reload loop), trusting the failure signal instead of `updated.check()` (which lies behind a CDN caching `version.json`).
10
+
11
+ ### Patch Changes
12
+
13
+ - [#321](https://github.com/jycouet/firstly/pull/321) [`27579a1`](https://github.com/jycouet/firstly/commit/27579a135f970e92b4f491c9c735b8200a49a73e) Thanks [@jycouet](https://github.com/jycouet)! - svelte: re-export `ffTrapFocus` from `firstly/svelte` so custom dialog shells can trap Tab focus without copying the implementation.
14
+
15
+ - [#325](https://github.com/jycouet/firstly/pull/325) [`0ae3295`](https://github.com/jycouet/firstly/commit/0ae32956dbe3e601e9e194a1275128b7d07612a3) Thanks [@jycouet](https://github.com/jycouet)! - sqlAdmin: run queries read-only by default (Postgres `READ ONLY` transaction, opt out via a UI checkbox), fix the missing row count, add a "copy as markdown" button, and restyle against the semantic theme tokens so it inherits the host app's theme.
16
+
17
+ ## 0.7.3
18
+
19
+ ### Patch Changes
20
+
21
+ - [#315](https://github.com/jycouet/firstly/pull/315) [`3403d6c`](https://github.com/jycouet/firstly/commit/3403d6cffa657ad04acdac26024bdf6e989df7b4) Thanks [@jycouet](https://github.com/jycouet)! - cron: quieter logs by default - a tick only logs `done in Xms` when it took at least `logs.ended` ms (default 100; `true` = always, `false` = never). `starting` and `result` lines are opt-in, `logs.setup` can silence the registration line. If `onTick` throws, the run is now stored as `failed` (error in `result`), always logged, and no longer leaks the concurrency slot.
22
+
3
23
  ## 0.7.2
4
24
 
5
25
  ### Patch Changes
@@ -14,5 +14,26 @@ export type HttpClientMiddleware = (next: HttpClientFetch) => HttpClientFetch;
14
14
  * ```
15
15
  */
16
16
  export declare function stackHttpClient(...middlewares: HttpClientMiddleware[]): HttpClientFetch;
17
+ export type ShortTermCacheOptions = {
18
+ /** How long a response is served from cache (default 2000ms). */
19
+ ttlMs?: number;
20
+ /**
21
+ * Decide per request if it is cacheable. Default: GET requests, plus remult's
22
+ * read-only POSTs (`__action=get` / `__action=groupBy`, used when a filter is too
23
+ * long for a query string).
24
+ */
25
+ shouldCache?: (input: RequestInfo | URL, init: RequestInit | undefined, cacheKey: string) => boolean;
26
+ };
27
+ /**
28
+ * Middleware that dedupes identical read requests within a short TTL - a tiny
29
+ * client-side cache. Two components mounting and issuing the same query result in
30
+ * ONE network call; each caller gets its own `Response` clone. Failed requests are
31
+ * evicted immediately so the next call retries.
32
+ *
33
+ * ```ts
34
+ * remult.apiClient.httpClient = stackHttpClient(withShortTermCache({ ttlMs: 2000 }))
35
+ * ```
36
+ */
37
+ export declare function withShortTermCache(opts?: ShortTermCacheOptions): HttpClientMiddleware;
17
38
  /** Middleware that sets a request header from `getValue()` when it returns a value. */
18
39
  export declare function withHeader(name: string, getValue: () => string | undefined | null): HttpClientMiddleware;
@@ -14,6 +14,64 @@
14
14
  export function stackHttpClient(...middlewares) {
15
15
  return middlewares.reduceRight((next, mw) => mw(next), (input, init) => fetch(input, init));
16
16
  }
17
+ const defaultShouldCache = (_input, init, key) => {
18
+ const method = init?.method?.toUpperCase() ?? 'GET';
19
+ if (method === 'GET')
20
+ return true;
21
+ if (method === 'POST')
22
+ return key.includes('__action=get') || key.includes('__action=groupBy');
23
+ return false;
24
+ };
25
+ /**
26
+ * Middleware that dedupes identical read requests within a short TTL - a tiny
27
+ * client-side cache. Two components mounting and issuing the same query result in
28
+ * ONE network call; each caller gets its own `Response` clone. Failed requests are
29
+ * evicted immediately so the next call retries.
30
+ *
31
+ * ```ts
32
+ * remult.apiClient.httpClient = stackHttpClient(withShortTermCache({ ttlMs: 2000 }))
33
+ * ```
34
+ */
35
+ export function withShortTermCache(opts = {}) {
36
+ const ttlMs = opts.ttlMs ?? 2000;
37
+ const shouldCache = opts.shouldCache ?? defaultShouldCache;
38
+ const cache = new Map();
39
+ return (next) => async (input, init) => {
40
+ let cacheKey = input.toString();
41
+ if (init?.body) {
42
+ const body = init.body;
43
+ const bodyStr = typeof body === 'string'
44
+ ? body
45
+ : body instanceof URLSearchParams
46
+ ? body.toString()
47
+ : JSON.stringify(body);
48
+ cacheKey = `${cacheKey}:${bodyStr}`;
49
+ }
50
+ if (!shouldCache(input, init, cacheKey)) {
51
+ return next(input, init);
52
+ }
53
+ const now = Date.now();
54
+ const cached = cache.get(cacheKey);
55
+ if (cached) {
56
+ if (now - cached.timestamp < ttlMs) {
57
+ const response = await cached.promise;
58
+ return response.clone();
59
+ }
60
+ cache.delete(cacheKey);
61
+ }
62
+ // Keep a pristine copy in cache; every consumer (incl. this one) reads a clone.
63
+ const promise = next(input, init).then((r) => r.clone());
64
+ cache.set(cacheKey, { promise, timestamp: now });
65
+ try {
66
+ const response = await promise;
67
+ return response.clone();
68
+ }
69
+ catch (e) {
70
+ cache.delete(cacheKey);
71
+ throw e;
72
+ }
73
+ };
74
+ }
17
75
  /** Middleware that sets a request header from `getValue()` when it returns a value. */
18
76
  export function withHeader(name, getValue) {
19
77
  return (next) => async (input, init) => {
@@ -0,0 +1,36 @@
1
+ import { type SubscriptionClient } from 'remult';
2
+ export type SubscriptionClientMiddleware = (next: SubscriptionClient) => SubscriptionClient;
3
+ /**
4
+ * Compose a `SubscriptionClient` from middlewares, with `directSseSubscriptionClient`
5
+ * at the bottom of the stack. With no middlewares, you get the bare direct SSE client.
6
+ *
7
+ * ```ts
8
+ * remult.apiClient.subscriptionClient = stackSubscriptionClient(withTabSharing())
9
+ * ```
10
+ */
11
+ export declare function stackSubscriptionClient(...middlewares: SubscriptionClientMiddleware[]): SubscriptionClient;
12
+ /**
13
+ * Middleware that shares ONE real SSE connection across all tabs of the same origin.
14
+ *
15
+ * Why: the browser's HTTP/1.1 ~6-connections-per-domain limit. With one EventSource
16
+ * per tab, opening 5+ tabs starves all other HTTP requests on the domain.
17
+ *
18
+ * How: `navigator.locks` elects a single leader tab. The leader opens the underlying
19
+ * (next) `SubscriptionClient` and forwards every channel message over a
20
+ * `BroadcastChannel` to follower tabs. Followers ask the leader to (un)subscribe
21
+ * the channels they care about; the leader refcounts interest per tab. On leader
22
+ * death (close/refresh), the lock is released and the next tab takes over,
23
+ * re-subscribing every channel any surviving tab still cares about.
24
+ *
25
+ * liveQuery support: a leader handoff and a real SSE reconnect both fan out a
26
+ * `reconnect` signal so EVERY tab fires its own `onReconnect`. Remult's
27
+ * LiveQueryClient then re-runs each query (refetch + resubscribe), covering
28
+ * messages lost in the gap. Plain `SubscriptionChannel` fanout works too, with the
29
+ * usual SSE semantics (no replay of messages missed during a reconnect).
30
+ */
31
+ export declare function withTabSharing(): SubscriptionClientMiddleware;
32
+ /**
33
+ * Minimal direct SSE-based `SubscriptionClient` - mirrors Remult's internal
34
+ * `SseSubscriptionClient`. Inlined because Remult does not publicly export it.
35
+ */
36
+ export declare function directSseSubscriptionClient(): SubscriptionClient;
@@ -0,0 +1,333 @@
1
+ import { remult, } from 'remult';
2
+ /**
3
+ * Compose a `SubscriptionClient` from middlewares, with `directSseSubscriptionClient`
4
+ * at the bottom of the stack. With no middlewares, you get the bare direct SSE client.
5
+ *
6
+ * ```ts
7
+ * remult.apiClient.subscriptionClient = stackSubscriptionClient(withTabSharing())
8
+ * ```
9
+ */
10
+ export function stackSubscriptionClient(...middlewares) {
11
+ return middlewares.reduceRight((next, mw) => mw(next), directSseSubscriptionClient());
12
+ }
13
+ const BUS_NAME = 'ff-sse-bus';
14
+ const LOCK_NAME = 'ff-sse-leader';
15
+ /**
16
+ * Middleware that shares ONE real SSE connection across all tabs of the same origin.
17
+ *
18
+ * Why: the browser's HTTP/1.1 ~6-connections-per-domain limit. With one EventSource
19
+ * per tab, opening 5+ tabs starves all other HTTP requests on the domain.
20
+ *
21
+ * How: `navigator.locks` elects a single leader tab. The leader opens the underlying
22
+ * (next) `SubscriptionClient` and forwards every channel message over a
23
+ * `BroadcastChannel` to follower tabs. Followers ask the leader to (un)subscribe
24
+ * the channels they care about; the leader refcounts interest per tab. On leader
25
+ * death (close/refresh), the lock is released and the next tab takes over,
26
+ * re-subscribing every channel any surviving tab still cares about.
27
+ *
28
+ * liveQuery support: a leader handoff and a real SSE reconnect both fan out a
29
+ * `reconnect` signal so EVERY tab fires its own `onReconnect`. Remult's
30
+ * LiveQueryClient then re-runs each query (refetch + resubscribe), covering
31
+ * messages lost in the gap. Plain `SubscriptionChannel` fanout works too, with the
32
+ * usual SSE semantics (no replay of messages missed during a reconnect).
33
+ */
34
+ export function withTabSharing() {
35
+ return (next) => {
36
+ if (typeof window === 'undefined' ||
37
+ typeof BroadcastChannel === 'undefined' ||
38
+ typeof navigator === 'undefined' ||
39
+ !('locks' in navigator)) {
40
+ return next;
41
+ }
42
+ return {
43
+ openConnection(onReconnect) {
44
+ return Promise.resolve(createSharedConnection(next, onReconnect));
45
+ },
46
+ };
47
+ };
48
+ }
49
+ function createSharedConnection(realClient, onReconnect) {
50
+ const tabId = typeof crypto !== 'undefined' && crypto.randomUUID
51
+ ? crypto.randomUUID()
52
+ : Math.random().toString(36).slice(2);
53
+ const localHandlers = new Map();
54
+ const bus = new BroadcastChannel(BUS_NAME);
55
+ let isLeader = false;
56
+ // True once another tab's leader served us - a later takeover is then a handoff.
57
+ let sawRemoteLeader = false;
58
+ let leaderConnection;
59
+ // Leader state: real subscription per channel + which tabs still want it.
60
+ const realSubs = new Map();
61
+ const interest = new Map();
62
+ let closed = false;
63
+ let closeResolver;
64
+ const dispatchLocally = (channel, message) => {
65
+ const handlers = localHandlers.get(channel);
66
+ if (handlers)
67
+ handlers.forEach((h) => h(message));
68
+ };
69
+ const addInterest = (channel, forTab) => {
70
+ let tabs = interest.get(channel);
71
+ if (!tabs)
72
+ interest.set(channel, (tabs = new Set()));
73
+ tabs.add(forTab);
74
+ };
75
+ const ensureRealSubscribed = async (channel, forTab) => {
76
+ if (!isLeader || !leaderConnection)
77
+ return;
78
+ addInterest(channel, forTab);
79
+ if (realSubs.has(channel))
80
+ return;
81
+ const promise = leaderConnection.subscribe(channel, (data) => {
82
+ dispatchLocally(channel, data);
83
+ bus.postMessage({ type: 'message', channel, data });
84
+ }, (err) => {
85
+ console.error('[withTabSharing] channel error', channel, err);
86
+ });
87
+ realSubs.set(channel, promise);
88
+ await promise;
89
+ };
90
+ const dropInterest = (channel, forTab) => {
91
+ if (!isLeader)
92
+ return;
93
+ const tabs = interest.get(channel);
94
+ if (!tabs)
95
+ return;
96
+ tabs.delete(forTab);
97
+ if (tabs.size > 0)
98
+ return;
99
+ interest.delete(channel);
100
+ const sub = realSubs.get(channel);
101
+ if (sub) {
102
+ realSubs.delete(channel);
103
+ sub.then((unsub) => unsub()).catch(() => { });
104
+ }
105
+ };
106
+ const dropTab = (forTab) => {
107
+ if (!isLeader)
108
+ return;
109
+ for (const channel of [...interest.keys()]) {
110
+ dropInterest(channel, forTab);
111
+ }
112
+ };
113
+ bus.onmessage = async (e) => {
114
+ const msg = e.data;
115
+ switch (msg.type) {
116
+ case 'message':
117
+ dispatchLocally(msg.channel, msg.data);
118
+ break;
119
+ case 'reconnect':
120
+ if (!isLeader)
121
+ onReconnect();
122
+ break;
123
+ case 'follower:subscribe':
124
+ if (isLeader)
125
+ await ensureRealSubscribed(msg.channel, msg.tabId);
126
+ break;
127
+ case 'follower:unsubscribe':
128
+ dropInterest(msg.channel, msg.tabId);
129
+ break;
130
+ case 'follower:bye':
131
+ dropTab(msg.tabId);
132
+ break;
133
+ case 'follower:channels':
134
+ if (isLeader) {
135
+ for (const ch of msg.channels)
136
+ await ensureRealSubscribed(ch, msg.tabId);
137
+ }
138
+ break;
139
+ case 'leader:hello':
140
+ if (!isLeader) {
141
+ const isHandoff = sawRemoteLeader;
142
+ sawRemoteLeader = true;
143
+ bus.postMessage({
144
+ type: 'follower:channels',
145
+ tabId,
146
+ channels: [...localHandlers.keys()],
147
+ });
148
+ // New leader means the previous SSE connection died with our
149
+ // subscriptions on it - resync liveQueries.
150
+ if (isHandoff)
151
+ onReconnect();
152
+ }
153
+ break;
154
+ }
155
+ };
156
+ navigator.locks
157
+ .request(LOCK_NAME, { mode: 'exclusive' }, async () => {
158
+ if (closed)
159
+ return;
160
+ try {
161
+ leaderConnection = await realClient.openConnection(() => {
162
+ // Real SSE reconnected: resync every tab, not just the leader.
163
+ onReconnect();
164
+ bus.postMessage({ type: 'reconnect' });
165
+ });
166
+ isLeader = true;
167
+ for (const ch of localHandlers.keys()) {
168
+ await ensureRealSubscribed(ch, tabId);
169
+ }
170
+ bus.postMessage({ type: 'leader:hello' });
171
+ // We were riding a dead leader's connection - resync our own queries.
172
+ if (sawRemoteLeader)
173
+ onReconnect();
174
+ }
175
+ catch (err) {
176
+ console.error('[withTabSharing] failed to become leader', err);
177
+ return;
178
+ }
179
+ await new Promise((resolve) => {
180
+ if (closed)
181
+ resolve();
182
+ closeResolver = resolve;
183
+ });
184
+ })
185
+ .catch((err) => {
186
+ console.error('[withTabSharing] lock request failed', err);
187
+ });
188
+ return {
189
+ close() {
190
+ if (closed)
191
+ return;
192
+ closed = true;
193
+ try {
194
+ bus.postMessage({ type: 'follower:bye', tabId });
195
+ bus.close();
196
+ }
197
+ catch {
198
+ // bus may already be gone (tab teardown)
199
+ }
200
+ if (leaderConnection) {
201
+ try {
202
+ leaderConnection.close();
203
+ }
204
+ catch {
205
+ // best effort
206
+ }
207
+ }
208
+ closeResolver?.();
209
+ },
210
+ async subscribe(channel, onMessage, _onError) {
211
+ let handlers = localHandlers.get(channel);
212
+ if (!handlers) {
213
+ handlers = new Set();
214
+ localHandlers.set(channel, handlers);
215
+ }
216
+ handlers.add(onMessage);
217
+ if (isLeader) {
218
+ await ensureRealSubscribed(channel, tabId);
219
+ }
220
+ else {
221
+ bus.postMessage({ type: 'follower:subscribe', tabId, channel });
222
+ }
223
+ return () => {
224
+ const set = localHandlers.get(channel);
225
+ if (!set)
226
+ return;
227
+ set.delete(onMessage);
228
+ if (set.size === 0) {
229
+ localHandlers.delete(channel);
230
+ if (isLeader) {
231
+ dropInterest(channel, tabId);
232
+ }
233
+ else {
234
+ bus.postMessage({
235
+ type: 'follower:unsubscribe',
236
+ tabId,
237
+ channel,
238
+ });
239
+ }
240
+ }
241
+ };
242
+ },
243
+ };
244
+ }
245
+ /**
246
+ * Minimal direct SSE-based `SubscriptionClient` - mirrors Remult's internal
247
+ * `SseSubscriptionClient`. Inlined because Remult does not publicly export it.
248
+ */
249
+ export function directSseSubscriptionClient() {
250
+ return {
251
+ openConnection(onReconnect) {
252
+ const channels = new Map();
253
+ let connectionId;
254
+ let source;
255
+ let connected = false;
256
+ let retryCount = 0;
257
+ const baseUrl = remult.apiClient.url ?? '/api';
258
+ const streamUrl = `${baseUrl}/stream`;
259
+ const post = async (path, body) => {
260
+ const res = await fetch(`${streamUrl}/${path}`, {
261
+ method: 'POST',
262
+ credentials: 'include',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify(body),
265
+ });
266
+ return res.json().catch(() => null);
267
+ };
268
+ const subscribeOnServer = async (channel) => {
269
+ if (!connectionId)
270
+ return;
271
+ await post('subscribe', { channel, clientId: connectionId });
272
+ };
273
+ const open = (resolveOnce) => {
274
+ if (source)
275
+ source.close();
276
+ source = new EventSource(streamUrl, { withCredentials: true });
277
+ source.onmessage = (e) => {
278
+ try {
279
+ const msg = JSON.parse(e.data);
280
+ const listeners = channels.get(msg.channel);
281
+ if (listeners)
282
+ listeners.forEach((l) => l(msg.data));
283
+ }
284
+ catch (err) {
285
+ console.error('[directSseSubscriptionClient] parse error', err);
286
+ }
287
+ };
288
+ source.onerror = () => {
289
+ source?.close();
290
+ if (retryCount++ < 10)
291
+ setTimeout(open, 500, resolveOnce);
292
+ };
293
+ source.addEventListener('connectionId', async (e) => {
294
+ connectionId = e.data;
295
+ if (connected) {
296
+ for (const ch of channels.keys())
297
+ await subscribeOnServer(ch);
298
+ onReconnect();
299
+ }
300
+ else {
301
+ connected = true;
302
+ resolveOnce(connection);
303
+ }
304
+ });
305
+ };
306
+ const connection = {
307
+ close() {
308
+ source?.close();
309
+ },
310
+ async subscribe(channel, handler, _onError) {
311
+ let listeners = channels.get(channel);
312
+ if (!listeners) {
313
+ channels.set(channel, (listeners = []));
314
+ await subscribeOnServer(channel);
315
+ }
316
+ listeners.push(handler);
317
+ return () => {
318
+ const idx = listeners.indexOf(handler);
319
+ if (idx >= 0)
320
+ listeners.splice(idx, 1);
321
+ if (listeners.length === 0) {
322
+ channels.delete(channel);
323
+ if (connectionId) {
324
+ post('unsubscribe', { channel, clientId: connectionId }).catch(() => { });
325
+ }
326
+ }
327
+ };
328
+ },
329
+ };
330
+ return new Promise((resolve) => open(resolve));
331
+ },
332
+ };
333
+ }
@@ -1,4 +1,4 @@
1
- declare const statuses: readonly ["starting", "ended", "skipped"];
1
+ declare const statuses: readonly ["starting", "ended", "skipped", "failed"];
2
2
  type StatusType = (typeof statuses)[number];
3
3
  export declare class Cron {
4
4
  id?: string;
package/esm/cron/Cron.js CHANGED
@@ -6,7 +6,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import { Entity, Fields } from 'remult';
8
8
  import { Roles_Cron } from './Roles_Cron';
9
- const statuses = ['starting', 'ended', 'skipped'];
9
+ const statuses = ['starting', 'ended', 'skipped', 'failed'];
10
10
  let Cron = class Cron {
11
11
  id;
12
12
  topic;
@@ -41,9 +41,17 @@ export type CronJobParams = {
41
41
  onTick: CronOnTick;
42
42
  topic: string;
43
43
  concurrent?: number;
44
+ /**
45
+ * Defaults: a tick logs `done in Xms` only when it took at least `ended` ms
46
+ * (default 100, `true` = always, `false` = never) + a `setup done` line at registration.
47
+ * `starting` & `result` are opt-in (full history incl. results is stored in `_ff_crons`).
48
+ * Failures and concurrency skips are always logged.
49
+ */
44
50
  logs?: {
51
+ setup?: boolean;
45
52
  starting?: boolean;
46
- ended?: boolean;
53
+ result?: boolean;
54
+ ended?: boolean | number;
47
55
  };
48
56
  start?: boolean;
49
57
  runOnInit?: boolean;
@@ -30,6 +30,8 @@ export const cronTime = {
30
30
  */
31
31
  every_friday_morning: '11 5 * * 5',
32
32
  };
33
+ const DEFAULT_ENDED_MIN_MS = 100;
34
+ const fmtDuration = (ms) => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`);
33
35
  /**
34
36
  * usage:
35
37
  *
@@ -59,47 +61,55 @@ export const cron = (jobsInfos) => {
59
61
  key: 'cron',
60
62
  entities: [Cron],
61
63
  });
62
- const logJobs = (topic, job, message, with_metadata = true, isSuccess = true) => {
63
- const l = [];
64
- l.push(magenta(`[${topic}]`));
65
- l.push(message);
66
- if (with_metadata) {
67
- // If the job is "stopped", there will still be a next date, but it will not fire it. The job has to start.
68
- l.push(`(${job.isActive ? green('running') : red('stopped')}, next at ${yellow(job.nextDate().toISO())})`);
69
- }
70
- if (isSuccess) {
71
- log.success(l.join(' '));
72
- }
73
- else {
74
- log.info(l.join(' '));
75
- }
76
- };
77
64
  m.initApi = async () => {
78
65
  jobsInfos.forEach((infos) => {
79
66
  const { topic, runOnInit, logs, concurrent, onTick: originalOnTick, ...params } = infos;
80
67
  const concurrentToUse = concurrent ?? 1;
68
+ const prefix = magenta(`[${topic}]`);
69
+ const endedMinMs = logs?.ended === false
70
+ ? Infinity
71
+ : logs?.ended === true
72
+ ? 0
73
+ : (logs?.ended ?? DEFAULT_ENDED_MIN_MS);
81
74
  // Create a wrapper that converts the return type to void for CronJob
82
75
  const wrappedOnTick = async () => {
83
- if (jobs[topic].concurrentInProgress < concurrentToUse) {
84
- jobs[topic].concurrentInProgress = jobs[topic].concurrentInProgress + 1;
85
- const rCron = await repo(Cron).insert({ topic });
86
- if (logs?.starting === undefined || logs?.starting === true) {
87
- logJobs(topic, job, 'starting...', false, false);
88
- }
76
+ if (jobs[topic].concurrentInProgress >= concurrentToUse) {
77
+ await repo(Cron).insert({ topic, status: 'skipped' });
78
+ log.info(`${prefix} skipped because of concurrent limit (${yellow(concurrentToUse.toString())})`);
79
+ return;
80
+ }
81
+ jobs[topic].concurrentInProgress++;
82
+ const startedAt = Date.now();
83
+ const rCron = await repo(Cron).insert({ topic });
84
+ if (logs?.starting) {
85
+ log.info(`${prefix} starting...`);
86
+ }
87
+ try {
89
88
  const res = await originalOnTick();
90
- log.info(`[${topic}] result:`, res);
91
89
  rCron.result = res;
92
90
  rCron.endedAt = new Date();
93
91
  rCron.status = 'ended';
94
92
  await repo(Cron).save(rCron);
95
- if (logs?.ended === undefined || logs?.ended === true) {
96
- logJobs(topic, job, 'done');
93
+ const ms = Date.now() - startedAt;
94
+ if (ms >= endedMinMs) {
95
+ const msg = `${prefix} done in ${fmtDuration(ms)}`;
96
+ if (logs?.result) {
97
+ log.success(msg, res);
98
+ }
99
+ else {
100
+ log.success(msg);
101
+ }
97
102
  }
98
- jobs[topic].concurrentInProgress = jobs[topic].concurrentInProgress - 1;
99
103
  }
100
- else {
101
- await repo(Cron).insert({ topic, status: 'skipped' });
102
- logJobs(topic, job, `skipped because of concurrent limit (${yellow(concurrentToUse.toString())})`, false, false);
104
+ catch (error) {
105
+ rCron.result = { error: error instanceof Error ? error.message : String(error) };
106
+ rCron.endedAt = new Date();
107
+ rCron.status = 'failed';
108
+ await repo(Cron).save(rCron);
109
+ log.error(`${prefix} failed after ${fmtDuration(Date.now() - startedAt)}`, error);
110
+ }
111
+ finally {
112
+ jobs[topic].concurrentInProgress--;
103
113
  }
104
114
  };
105
115
  // Use type assertion to bypass complex generic type issues
@@ -108,7 +118,10 @@ export const cron = (jobsInfos) => {
108
118
  onTick: wrappedOnTick,
109
119
  });
110
120
  jobs[topic] = { job, concurrentInProgress: 0 };
111
- logJobs(topic, job, 'setup done');
121
+ if (logs?.setup !== false) {
122
+ // A stopped job still reports a next date, it just won't fire it.
123
+ log.success(`${prefix} setup done (${job.isActive ? green('running') : red('stopped')}, next at ${yellow(job.nextDate().toISO())})`);
124
+ }
112
125
  // If not it will be done too early
113
126
  if (runOnInit) {
114
127
  job.fireOnTick();
package/esm/index.d.ts CHANGED
@@ -15,8 +15,10 @@ 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
+ export { stackHttpClient, withHeader, withShortTermCache } from './core/httpClientStack.js';
19
+ export type { HttpClientFetch, HttpClientMiddleware, ShortTermCacheOptions, } from './core/httpClientStack.js';
20
+ export { stackSubscriptionClient, withTabSharing, directSseSubscriptionClient, } from './core/subscriptionClientStack.js';
21
+ export type { SubscriptionClientMiddleware } from './core/subscriptionClientStack.js';
20
22
  export { FF_LogToConsole } from './SqlDatabase/FF_LogToConsole.js';
21
23
  export { FilterEntity } from './virtual/FilterEntity.js';
22
24
  export { UIEntity } from './virtual/UIEntity.js';
package/esm/index.js CHANGED
@@ -15,7 +15,8 @@ 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
+ export { stackHttpClient, withHeader, withShortTermCache } from './core/httpClientStack.js';
19
+ export { stackSubscriptionClient, withTabSharing, directSseSubscriptionClient, } from './core/subscriptionClientStack.js';
19
20
  // Misc primitives still exposed from the root.
20
21
  export { FF_LogToConsole } from './SqlDatabase/FF_LogToConsole.js';
21
22
  export { FilterEntity } from './virtual/FilterEntity.js';
@@ -2,8 +2,16 @@ import { SqlDatabase } from 'remult';
2
2
  export declare class SqlAdminController {
3
3
  /** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
4
4
  static dp?: SqlDatabase;
5
- static exec(cmd: string): Promise<{
6
- r: import("remult").SqlResult;
5
+ /**
6
+ * @param cmd SQL to run.
7
+ * @param notReadOnly When `false` (default) the query runs inside a
8
+ * `READ ONLY` transaction so the database itself rejects any write
9
+ * (INSERT/UPDATE/DELETE/DDL). Set `true` only when you deliberately want
10
+ * to mutate - the UI gates this behind an explicit checkbox.
11
+ */
12
+ static exec(cmd: string, notReadOnly?: boolean): Promise<{
13
+ rows: any[];
14
+ rowCount: number;
7
15
  took: number;
8
16
  }>;
9
17
  }
@@ -10,12 +10,30 @@ import { Roles_SqlAdmin } from './Roles_SqlAdmin';
10
10
  export class SqlAdminController {
11
11
  /** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
12
12
  static dp;
13
- static async exec(cmd) {
13
+ /**
14
+ * @param cmd SQL to run.
15
+ * @param notReadOnly When `false` (default) the query runs inside a
16
+ * `READ ONLY` transaction so the database itself rejects any write
17
+ * (INSERT/UPDATE/DELETE/DDL). Set `true` only when you deliberately want
18
+ * to mutate - the UI gates this behind an explicit checkbox.
19
+ */
20
+ static async exec(cmd, notReadOnly = false) {
14
21
  const db = SqlAdminController.dp ?? SqlDatabase.getDb();
15
22
  const start = performance.now();
16
- const r = await db.execute(cmd);
23
+ let rows = [];
24
+ if (notReadOnly) {
25
+ rows = (await db.execute(cmd)).rows;
26
+ }
27
+ else {
28
+ await db.transaction(async (tx) => {
29
+ const txDb = SqlDatabase.getDb(tx);
30
+ // Postgres: makes the whole transaction reject writes at the DB level.
31
+ await txDb.execute('SET TRANSACTION READ ONLY');
32
+ rows = (await txDb.execute(cmd)).rows;
33
+ });
34
+ }
17
35
  const took = performance.now() - start;
18
- return { r, took };
36
+ return { rows, rowCount: rows.length, took };
19
37
  }
20
38
  }
21
39
  __decorate([
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * SQL Admin UI.
4
4
  *
5
- * Dark theme (zinc + indigo accent), styled with raw Tailwind utilities only -
6
- * no plugin (daisyUI, shadcn, etc.) required. Drop into any Tailwind-powered
7
- * project and it just works.
5
+ * Styled against the semantic theme tokens (`bg-card`, `text-foreground`,
6
+ * `border-border`, `bg-primary`, `bg-destructive`, ...) defined by the host
7
+ * app, so it inherits the userland theme instead of a hard-coded palette.
8
8
  *
9
9
  * Results are logged to the browser console as `for AI: <json rows>` after
10
10
  * each successful query - chrome-devtools / AI agents inspecting the page
@@ -18,9 +18,11 @@ FROM "public"."users"
18
18
  LIMIT 10`
19
19
 
20
20
  let sqlInput = $state(defaultQuery)
21
- let result: any = $state()
21
+ let result: { rows: any[]; rowCount: number; took: number } | undefined = $state()
22
22
  let error = $state('')
23
23
  let isLoading = $state(false)
24
+ let notReadOnly = $state(false)
25
+ let copied = $state(false)
24
26
 
25
27
  const queries = {
26
28
  default: { label: 'Default', sql: defaultQuery },
@@ -62,8 +64,8 @@ ORDER BY tablename, indexname`,
62
64
  try {
63
65
  error = ''
64
66
  isLoading = true
65
- result = { ...(await SqlAdminController.exec(sqlInput)) }
66
- log.info('for AI:', JSON.stringify(result.r.rows))
67
+ result = await SqlAdminController.exec(sqlInput, notReadOnly)
68
+ log.info('for AI:', JSON.stringify(result.rows))
67
69
  log.info('for humans:', result)
68
70
  } catch (e) {
69
71
  error = JSON.stringify(e, null, 2)
@@ -76,12 +78,42 @@ ORDER BY tablename, indexname`,
76
78
  if (!rows || rows.length === 0) return []
77
79
  return Object.keys(rows[0])
78
80
  }
81
+
82
+ function cellToText(cell: unknown): string {
83
+ if (cell === null || cell === undefined) return ''
84
+ if (typeof cell === 'object') return JSON.stringify(cell)
85
+ return String(cell)
86
+ }
87
+
88
+ /** Render the result as a GitHub-flavoured markdown table (paste into a chat). */
89
+ function toMarkdown(rows: any[]): string {
90
+ const headers = getHeaders(rows)
91
+ if (headers.length === 0) return ''
92
+ const esc = (s: string) => s.replaceAll('|', '\\|').replaceAll('\n', ' ')
93
+ const head = `| ${headers.map(esc).join(' | ')} |`
94
+ const sep = `| ${headers.map(() => '---').join(' | ')} |`
95
+ const body = rows
96
+ .map((row) => `| ${headers.map((h) => esc(cellToText(row[h]))).join(' | ')} |`)
97
+ .join('\n')
98
+ return `${head}\n${sep}\n${body}`
99
+ }
100
+
101
+ async function copyTable() {
102
+ if (!result?.rows?.length) return
103
+ try {
104
+ await navigator.clipboard.writeText(toMarkdown(result.rows))
105
+ copied = true
106
+ setTimeout(() => (copied = false), 1500)
107
+ } catch (e) {
108
+ log.error('copy failed', e)
109
+ }
110
+ }
79
111
  </script>
80
112
 
81
- <div class="border border-slate-700 bg-slate-800 text-slate-200">
82
- <header class="border-b border-slate-700 px-5 py-4">
83
- <h2 class="text-lg font-semibold text-slate-100">SQL Admin</h2>
84
- <p class="mt-1 text-sm text-slate-400">
113
+ <div class="border border-border bg-card text-card-foreground">
114
+ <header class="border-b border-border px-5 py-4">
115
+ <h2 class="text-lg font-semibold text-foreground">SQL Admin</h2>
116
+ <p class="mt-1 text-sm text-muted-foreground">
85
117
  Execute SQL queries directly on the database. Results are displayed below and also logged to the
86
118
  browser console.
87
119
  </p>
@@ -92,7 +124,7 @@ ORDER BY tablename, indexname`,
92
124
  {#each Object.entries(queries) as [id, query] (id)}
93
125
  <button
94
126
  type="button"
95
- class="border border-slate-600 bg-slate-700 px-3 py-1.5 text-sm font-medium text-slate-100 hover:bg-slate-600 disabled:opacity-50"
127
+ class="border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-secondary-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
96
128
  onclick={() => setPresetQuery(id as keyof typeof queries)}>{query.label}</button
97
129
  >
98
130
  {/each}
@@ -100,14 +132,14 @@ ORDER BY tablename, indexname`,
100
132
  <form onsubmit={handleSubmit} class="flex flex-col gap-4">
101
133
  <textarea
102
134
  bind:value={sqlInput}
103
- class="h-52 w-full border border-slate-700 bg-slate-900 p-3 font-mono text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-400 focus:outline-none disabled:opacity-50"
135
+ class="h-52 w-full border border-input bg-background p-3 font-mono text-sm text-foreground placeholder-muted-foreground focus:border-ring focus:outline-none disabled:opacity-50"
104
136
  placeholder="Enter SQL command..."
105
137
  disabled={isLoading}
106
138
  ></textarea>
107
139
  <div class="flex flex-wrap items-center gap-4">
108
140
  <button
109
141
  type="submit"
110
- class="inline-flex items-center gap-2 bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
142
+ class="inline-flex items-center gap-2 bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
111
143
  disabled={isLoading}
112
144
  >
113
145
  {#if isLoading}
@@ -126,55 +158,78 @@ ORDER BY tablename, indexname`,
126
158
  {/if}
127
159
  Execute SQL
128
160
  </button>
161
+
162
+ <label
163
+ class="inline-flex items-center gap-2 text-sm font-medium select-none"
164
+ class:text-destructive={notReadOnly}
165
+ class:text-muted-foreground={!notReadOnly}
166
+ title="Read-only runs your query in a READ ONLY transaction so the database rejects any write. Tick this only when you know what you are doing."
167
+ >
168
+ <input type="checkbox" bind:checked={notReadOnly} class="accent-destructive" />
169
+ {notReadOnly ? 'Writes enabled (I know what I am doing)' : 'Read-only'}
170
+ </label>
171
+
129
172
  {#if error}
130
173
  <pre
131
- class="flex-1 overflow-auto border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{error.replaceAll(
174
+ class="flex-1 overflow-auto border border-destructive bg-destructive/10 p-3 text-sm text-destructive">{error.replaceAll(
132
175
  '\\n',
133
176
  '\n',
134
177
  )}</pre>
135
178
  {/if}
136
179
  {#if result}
137
180
  <div
138
- class="flex flex-1 justify-between border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm text-emerald-200"
181
+ class="flex flex-1 items-center justify-between gap-3 border border-border bg-muted p-3 text-sm text-muted-foreground"
139
182
  >
140
183
  <span>{result.took.toFixed(0)} ms</span>
141
- <span>{result.r.rowCount} rows</span>
184
+ <span>{result.rowCount} row{result.rowCount === 1 ? '' : 's'}</span>
142
185
  </div>
143
186
  {/if}
144
187
  </div>
145
188
  </form>
146
189
  {#if result}
190
+ {#if result.rows && result.rows.length > 0}
191
+ <div class="flex items-center justify-between">
192
+ <span class="text-sm text-muted-foreground">
193
+ {result.rowCount} row{result.rowCount === 1 ? '' : 's'}
194
+ </span>
195
+ <button
196
+ type="button"
197
+ onclick={copyTable}
198
+ class="inline-flex items-center gap-2 border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-secondary-foreground hover:bg-accent hover:text-accent-foreground"
199
+ >
200
+ {copied ? 'Copied!' : 'Copy as markdown'}
201
+ </button>
202
+ </div>
203
+ {/if}
147
204
  <!-- contain: paint isolates the scroll container's repaint area; without
148
205
  it, scrolling a wide result table forces the whole page to repaint
149
206
  every frame, which is what made horizontal scroll feel laggy. -->
150
- <div class="max-h-[600px] overflow-auto border border-slate-700 [contain:paint]">
151
- {#if result.r.rows && result.r.rows.length > 0}
207
+ <div class="max-h-[600px] overflow-auto border border-border [contain:paint]">
208
+ {#if result.rows && result.rows.length > 0}
152
209
  <table class="w-full border-collapse text-sm">
153
- <thead class="sticky top-0 z-10 bg-slate-700">
210
+ <thead class="sticky top-0 z-10 bg-secondary">
154
211
  <tr>
155
- {#each getHeaders(result.r.rows) as header, i (i)}
156
- <th class="border-b border-slate-600 px-3 py-2 text-left font-semibold text-slate-100"
212
+ {#each getHeaders(result.rows) as header, i (i)}
213
+ <th
214
+ class="border-b border-border px-3 py-2 text-left font-semibold text-secondary-foreground"
157
215
  >{header}</th
158
216
  >
159
217
  {/each}
160
218
  </tr>
161
219
  </thead>
162
220
  <tbody>
163
- {#each result.r.rows as row, r (r)}
164
- <!-- Solid stripe (no /50 alpha) so the GPU doesn't have to alpha-
165
- composite every cell on every scroll frame. -->
166
- <tr class="even:bg-slate-900">
221
+ {#each result.rows as row, r (r)}
222
+ <tr class="even:bg-muted/40">
167
223
  {#each Object.values(row) as cell, c (c)}
168
224
  <!-- min-w to keep short cells readable, max-w-xs to cap
169
225
  wide ones, break-all so long unbroken strings (URLs,
170
226
  DIDs) wrap inside their cell instead of forcing the
171
- column to ~940px and the table to 2.5kpx (which is
172
- what made horizontal scroll laggy). -->
227
+ column too wide and making horizontal scroll laggy. -->
173
228
  <td
174
- class="max-w-xs min-w-[8rem] border-b border-slate-700 px-3 py-2 align-top text-sm break-all text-slate-200"
229
+ class="max-w-xs min-w-[8rem] border-b border-border px-3 py-2 align-top text-sm break-all text-foreground"
175
230
  >
176
- {#if typeof cell === 'object'}<pre
177
- class="text-xs whitespace-pre-wrap text-slate-400">{JSON.stringify(
231
+ {#if typeof cell === 'object' && cell !== null}<pre
232
+ class="text-xs whitespace-pre-wrap text-muted-foreground">{JSON.stringify(
178
233
  cell,
179
234
  null,
180
235
  2,
@@ -187,9 +242,7 @@ ORDER BY tablename, indexname`,
187
242
  </tbody>
188
243
  </table>
189
244
  {:else}
190
- <div class="border border-slate-700 bg-slate-800 p-3 text-sm text-slate-300">
191
- No rows returned
192
- </div>
245
+ <div class="bg-card p-3 text-sm text-muted-foreground">No rows returned</div>
193
246
  {/if}
194
247
  </div>
195
248
  {/if}
@@ -0,0 +1,31 @@
1
+ import type { HandleClientError } from '@sveltejs/kit';
2
+ export type HandleClientErrorMiddleware = (next: HandleClientError) => HandleClientError;
3
+ /**
4
+ * Compose `handleError` middlewares into a single `hooks.client.js` handler.
5
+ * Middlewares run outermost-first (the first argument wraps the rest). A middleware
6
+ * either short-circuits (return without calling `next` - e.g. a hard reload) or calls
7
+ * `next(input)`. The base returns `{ message: 'Something went wrong' }`.
8
+ *
9
+ * ```js
10
+ * // src/hooks.client.js
11
+ * import { stackHandleClientError, withStaleDeployReload } from './'
12
+ *
13
+ * export const handleError = stackHandleClientError(
14
+ * withStaleDeployReload(),
15
+ * (next) => (input) => {
16
+ * report(input.error) // your logging
17
+ * return next(input) // or return { message: 'Oops' } to stop here
18
+ * },
19
+ * )
20
+ * ```
21
+ */
22
+ export declare function stackHandleClientError(...middlewares: HandleClientErrorMiddleware[]): HandleClientError;
23
+ /**
24
+ * Middleware that recovers stale-deploy chunk failures: after a deploy an open client
25
+ * (esp. SPA builds) 404s on a lazy chunk. Trusts the failure signal itself, not
26
+ * `updated.check()` (which lies behind a CDN caching `version.json`), and hard-reloads
27
+ * once - a time-boxed one-shot guard prevents reload loops. Only chunk-load errors
28
+ * reload (a blind 404 reload would break client-side not-found routes); anything else
29
+ * falls through to `next`.
30
+ */
31
+ export declare function withStaleDeployReload(): HandleClientErrorMiddleware;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Compose `handleError` middlewares into a single `hooks.client.js` handler.
3
+ * Middlewares run outermost-first (the first argument wraps the rest). A middleware
4
+ * either short-circuits (return without calling `next` - e.g. a hard reload) or calls
5
+ * `next(input)`. The base returns `{ message: 'Something went wrong' }`.
6
+ *
7
+ * ```js
8
+ * // src/hooks.client.js
9
+ * import { stackHandleClientError, withStaleDeployReload } from './'
10
+ *
11
+ * export const handleError = stackHandleClientError(
12
+ * withStaleDeployReload(),
13
+ * (next) => (input) => {
14
+ * report(input.error) // your logging
15
+ * return next(input) // or return { message: 'Oops' } to stop here
16
+ * },
17
+ * )
18
+ * ```
19
+ */
20
+ export function stackHandleClientError(...middlewares) {
21
+ return middlewares.reduceRight((next, mw) => mw(next), () => ({ message: 'Something went wrong' }));
22
+ }
23
+ // Errors SvelteKit/Vite throw when a lazy chunk 404s after a deploy.
24
+ const CHUNK_LOAD_RE = /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/;
25
+ const STORAGE_KEY = 'ff-reload';
26
+ // A URL that failed again within this window is a genuine break, not a stale deploy.
27
+ const RETRY_WINDOW_MS = 10_000;
28
+ /**
29
+ * Middleware that recovers stale-deploy chunk failures: after a deploy an open client
30
+ * (esp. SPA builds) 404s on a lazy chunk. Trusts the failure signal itself, not
31
+ * `updated.check()` (which lies behind a CDN caching `version.json`), and hard-reloads
32
+ * once - a time-boxed one-shot guard prevents reload loops. Only chunk-load errors
33
+ * reload (a blind 404 reload would break client-side not-found routes); anything else
34
+ * falls through to `next`.
35
+ */
36
+ export function withStaleDeployReload() {
37
+ return (next) => (input) => {
38
+ if (CHUNK_LOAD_RE.test(String(input.message)) && reloadOnce(input.event.url.href)) {
39
+ return; // page is unloading; no error surfaces
40
+ }
41
+ return next(input);
42
+ };
43
+ }
44
+ /** Hard-reload `href` unless it already failed within the guard window. Returns true if reloading. */
45
+ function reloadOnce(href) {
46
+ try {
47
+ const prev = sessionStorage.getItem(STORAGE_KEY);
48
+ if (prev) {
49
+ const [prevHref, prevAt] = prev.split('\n');
50
+ if (prevHref === href && Date.now() - Number(prevAt) < RETRY_WINDOW_MS) {
51
+ sessionStorage.removeItem(STORAGE_KEY); // genuine break -> stop retrying
52
+ return false;
53
+ }
54
+ }
55
+ sessionStorage.setItem(STORAGE_KEY, `${href}\n${Date.now()}`);
56
+ location.assign(href); // bypasses SvelteKit's stale version check
57
+ return true;
58
+ }
59
+ catch {
60
+ return false; // no sessionStorage (private mode) -> don't risk a loop
61
+ }
62
+ }
@@ -3,7 +3,9 @@ export { ff } from './ff.svelte.js';
3
3
  export type { FF_Many, FF_One, FF_Builder, FF_RepoOptions, FF_OneOptions, FF_RepoLoading, FF_Issue, ManyStrategy, AggregateOptions, QueryOptionsHelper, } from './ff.svelte.js';
4
4
  export { infiniteScroll } from './infiniteScroll.js';
5
5
  export type { InfiniteScrollOptions } from './infiniteScroll.js';
6
- export { dialog, ffAutofocus, resolveMessage } from './dialog.svelte.js';
6
+ export { stackHandleClientError, withStaleDeployReload } from './handleError.js';
7
+ export type { HandleClientErrorMiddleware } from './handleError.js';
8
+ export { dialog, ffAutofocus, ffTrapFocus, resolveMessage } from './dialog.svelte.js';
7
9
  export type { DialogResult, DialogClose, DialogOptions, DialogItem, DialogRender, ConfirmItem, PromptItem, DialogShellArgs, DialogConfirmArgs, DialogPromptArgs, } from './dialog.svelte.js';
8
10
  export type { LocalizedMessage } from '../core/FF_Validators.js';
9
11
  export { default as FF_DialogManager } from './FF_DialogManager.svelte';
@@ -1,7 +1,8 @@
1
1
  export { errorMessage, isError } from '../core/helper.js';
2
2
  export { ff } from './ff.svelte.js';
3
3
  export { infiniteScroll } from './infiniteScroll.js';
4
- export { dialog, ffAutofocus, resolveMessage } from './dialog.svelte.js';
4
+ export { stackHandleClientError, withStaleDeployReload } from './handleError.js';
5
+ export { dialog, ffAutofocus, ffTrapFocus, resolveMessage } from './dialog.svelte.js';
5
6
  export { default as FF_DialogManager } from './FF_DialogManager.svelte';
6
7
  export { default as FF_Config } from './FF_Config.svelte';
7
8
  export { ffConfig, setFFConfig } from './FF_Config.svelte.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firstly",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Firstly, an opinionated Remult setup!",
6
6
  "funding": "https://github.com/sponsors/jycouet",