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 +20 -0
- package/esm/core/httpClientStack.d.ts +21 -0
- package/esm/core/httpClientStack.js +58 -0
- package/esm/core/subscriptionClientStack.d.ts +36 -0
- package/esm/core/subscriptionClientStack.js +333 -0
- package/esm/cron/Cron.d.ts +1 -1
- package/esm/cron/Cron.js +1 -1
- package/esm/cron/server/index.d.ts +9 -1
- package/esm/cron/server/index.js +42 -29
- package/esm/index.d.ts +4 -2
- package/esm/index.js +2 -1
- package/esm/sqlAdmin/SqlAdminController.d.ts +10 -2
- package/esm/sqlAdmin/SqlAdminController.js +21 -3
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +86 -33
- package/esm/svelte/handleError.d.ts +31 -0
- package/esm/svelte/handleError.js +62 -0
- package/esm/svelte/index.d.ts +3 -1
- package/esm/svelte/index.js +2 -1
- package/package.json +1 -1
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
|
+
}
|
package/esm/cron/Cron.d.ts
CHANGED
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
|
-
|
|
53
|
+
result?: boolean;
|
|
54
|
+
ended?: boolean | number;
|
|
47
55
|
};
|
|
48
56
|
start?: boolean;
|
|
49
57
|
runOnInit?: boolean;
|
package/esm/cron/server/index.js
CHANGED
|
@@ -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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 =
|
|
66
|
-
log.info('for AI:', JSON.stringify(result.
|
|
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-
|
|
82
|
-
<header class="border-b border-
|
|
83
|
-
<h2 class="text-lg font-semibold text-
|
|
84
|
-
<p class="mt-1 text-sm text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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-
|
|
151
|
-
{#if result.
|
|
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-
|
|
210
|
+
<thead class="sticky top-0 z-10 bg-secondary">
|
|
154
211
|
<tr>
|
|
155
|
-
{#each getHeaders(result.
|
|
156
|
-
<th
|
|
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.
|
|
164
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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="
|
|
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
|
+
}
|
package/esm/svelte/index.d.ts
CHANGED
|
@@ -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 {
|
|
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';
|
package/esm/svelte/index.js
CHANGED
|
@@ -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 {
|
|
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';
|