@k2b/cloud 0.11.1 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -31,6 +31,7 @@
31
31
  "./browser": "./src/server/api-client.ts",
32
32
  "./browser/app-approval": "./src/browser/app-approval.ts",
33
33
  "./browser/live": "./src/browser/live-websocket.ts",
34
+ "./browser/mermaid": "./src/browser/mermaid.ts",
34
35
  "./browser/notifications": "./src/browser/notifications.ts",
35
36
  "./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
36
37
  "./browser/commands": "./src/browser/commands.ts",
@@ -84,8 +85,7 @@
84
85
  "./styles/global.css": "./src/styles/global.css"
85
86
  },
86
87
  "scripts": {
87
- "test": "bun test --isolate --path-ignore-patterns '**/*.behavior.test.tsx' --path-ignore-patterns '**/*.dom.test.tsx' && bun run test:dom",
88
- "test:dom": "bun test --isolate --conditions=browser --preload ../ui/test/solid-dom-preload.ts behavior.test.tsx dom.test.tsx",
88
+ "test": "bun test --isolate --path-ignore-patterns '**/*.behavior.test.*'",
89
89
  "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
90
90
  },
91
91
  "dependencies": {
@@ -109,7 +109,7 @@
109
109
  "katex": "0.18.7",
110
110
  "liquidjs": "10.29.0",
111
111
  "marked": "17.0.6",
112
- "mermaid": "11.16.0",
112
+ "mermaid": "12.0.0",
113
113
  "nodemailer": "10.0.10",
114
114
  "postcss": "8.5.28",
115
115
  "reflect-metadata": "0.2.2",
package/src/ai/store.ts CHANGED
@@ -1095,10 +1095,10 @@ export const aiConversations: AiConversationService = {
1095
1095
  const refs = input.refs ?? [];
1096
1096
  const rows = await withConversationSearchBackend(query, async (backend) => {
1097
1097
  const order = query
1098
- ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
1098
+ ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`
1099
1099
  : input.done === true
1100
1100
  ? sql`conversation.last_used_at DESC, conversation.id DESC`
1101
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1101
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`;
1102
1102
  return sql<ConversationRow[]>`
1103
1103
  SELECT
1104
1104
  conversation.*,
@@ -1198,10 +1198,10 @@ export const aiConversations: AiConversationService = {
1198
1198
  const { page, perPage, offset } = sanitizePagination(input);
1199
1199
  const rows = await withConversationSearchBackend(query, async (backend) => {
1200
1200
  const order = query
1201
- ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
1201
+ ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`
1202
1202
  : input.done === true
1203
1203
  ? sql`conversation.last_used_at DESC, conversation.id DESC`
1204
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1204
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`;
1205
1205
  return sql<ConversationRow[]>`
1206
1206
  SELECT
1207
1207
  conversation.*,
@@ -52,6 +52,16 @@ const DEFAULT_RECONNECT = {
52
52
  jitterMs: 250,
53
53
  } as const;
54
54
 
55
+ /**
56
+ * Deadline for one opening handshake. The handshake is a single HTTP upgrade
57
+ * round trip (the gateway accepts it before reaching the application), so a
58
+ * healthy attempt finishes far sooner. Browsers can otherwise leave a socket in
59
+ * `CONNECTING` indefinitely after sleep or a network change. Matching the
60
+ * longest default reconnect delay means a stalled attempt never blocks recovery
61
+ * for longer than an ordinary retry.
62
+ */
63
+ const CONNECT_TIMEOUT_MS = DEFAULT_RECONNECT.maxDelayMs;
64
+
55
65
  const defaultCloseError = ({ code, reason }: LiveWebSocketClose): LiveWebSocketError | null => {
56
66
  if (code === 1008) return { code: reason || "access_denied", message: "Live access changed or expired." };
57
67
  if (code === 1011) return { code: reason || "internal_error", message: "Live updates failed." };
@@ -72,7 +82,8 @@ const socketUrl = (raw: string): string => {
72
82
  *
73
83
  * The app owns its wire protocol, validation, permissions, and domain updates.
74
84
  * This helper owns only browser transport lifecycle: one socket, visibility,
75
- * reconnect backoff, applied-cursor resume, terminal closes, and disposal.
85
+ * reconnect backoff, connection deadlines, recovery when the tab or network
86
+ * returns, applied-cursor resume, terminal closes, and disposal.
76
87
  */
77
88
  export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMessage>): LiveWebSocket => {
78
89
  const activity = options.activity ?? "visible";
@@ -81,6 +92,8 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
81
92
 
82
93
  let socket: WebSocket | null = null;
83
94
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
95
+ let connectTimer: ReturnType<typeof setTimeout> | null = null;
96
+ let connectStartedAt = 0;
84
97
  let reconnectAttempt = 0;
85
98
  let lastAppliedCursor = options.initialCursor ?? null;
86
99
  let started = false;
@@ -120,9 +133,15 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
120
133
  }
121
134
  };
122
135
 
136
+ const clearConnectDeadline = () => {
137
+ if (connectTimer !== null) clearTimeout(connectTimer);
138
+ connectTimer = null;
139
+ };
140
+
123
141
  const closeSocket = (code = 1000, reason = "") => {
124
142
  const current = socket;
125
143
  socket = null;
144
+ clearConnectDeadline();
126
145
  if (!current || current.readyState > WebSocket.OPEN) return;
127
146
  try {
128
147
  current.close(code, reason);
@@ -135,6 +154,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
135
154
  if (terminated) return;
136
155
  terminated = true;
137
156
  clearReconnect();
157
+ detachRecovery();
138
158
  closeSocket(close.code, close.reason);
139
159
  setStatus("closed");
140
160
  if (!fatalSent) {
@@ -165,6 +185,13 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
165
185
  }, delay);
166
186
  };
167
187
 
188
+ /** Gives up on an attempt that is still connecting and schedules a retry. */
189
+ const abandonAttempt = (attempt: WebSocket) => {
190
+ if (socket !== attempt) return;
191
+ closeSocket();
192
+ scheduleReconnect();
193
+ };
194
+
168
195
  const openSocket = () => {
169
196
  if (disposed || terminated || !started || socket) return;
170
197
  if (!browserAvailable()) {
@@ -174,7 +201,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
174
201
  if (!isActive()) return;
175
202
 
176
203
  let next: WebSocket;
177
- setStatus(reconnectAttempt > 0 ? "reconnecting" : "connecting");
204
+ setStatus(reconnectAttempt > 0 || status === "reconnecting" ? "reconnecting" : "connecting");
178
205
  try {
179
206
  next = new WebSocket(socketUrl(typeof options.url === "function" ? options.url() : options.url));
180
207
  } catch (error) {
@@ -182,9 +209,15 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
182
209
  return;
183
210
  }
184
211
  socket = next;
212
+ connectStartedAt = Date.now();
213
+ connectTimer = setTimeout(() => {
214
+ connectTimer = null;
215
+ if (next.readyState === WebSocket.CONNECTING) abandonAttempt(next);
216
+ }, CONNECT_TIMEOUT_MS);
185
217
 
186
218
  next.onopen = () => {
187
219
  if (socket !== next || disposed || terminated) return;
220
+ clearConnectDeadline();
188
221
  try {
189
222
  if (!send(options.subscribe(lastAppliedCursor))) throw new Error("Live WebSocket subscription could not be sent");
190
223
  setStatus("open");
@@ -213,6 +246,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
213
246
  next.onclose = (event) => {
214
247
  if (socket !== next) return;
215
248
  socket = null;
249
+ clearConnectDeadline();
216
250
  if (disposed || terminated) return;
217
251
  const closeError = classifyClose({ code: event.code, reason: event.reason.trim() });
218
252
  if (closeError) fatal(closeError, { code: event.code, reason: event.reason });
@@ -224,22 +258,55 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
224
258
  next.close();
225
259
  } catch {
226
260
  if (socket === next) {
227
- socket = null;
261
+ closeSocket();
228
262
  scheduleReconnect();
229
263
  }
230
264
  }
231
265
  };
232
266
  };
233
267
 
234
- const syncVisibility = () => {
235
- if (isActive()) openSocket();
236
- else {
268
+ /**
269
+ * Reconnects now when the tab or network returns. Open and closing sockets
270
+ * are left alone; a closing socket's close event still decides between retry
271
+ * and a terminal error. A handshake within its deadline is kept unless the
272
+ * network just returned, because it began on the network that was gone. A
273
+ * stalled handshake or a pending backoff is replaced by an immediate attempt
274
+ * whose backoff starts over.
275
+ */
276
+ const recover = (event: Event) => {
277
+ if (disposed || terminated || !started || !isActive()) return;
278
+ if (socket) {
279
+ if (socket.readyState !== WebSocket.CONNECTING) return;
280
+ if (event.type !== "online" && Date.now() - connectStartedAt < CONNECT_TIMEOUT_MS) return;
281
+ closeSocket();
282
+ }
283
+ clearReconnect();
284
+ reconnectAttempt = 0;
285
+ openSocket();
286
+ };
287
+
288
+ const syncVisibility = (event: Event) => {
289
+ if (isActive()) recover(event);
290
+ else if (activity === "visible") {
237
291
  clearReconnect();
238
292
  closeSocket(1000, "inactive");
239
293
  setStatus("paused");
240
294
  }
241
295
  };
242
296
 
297
+ const attachRecovery = () => {
298
+ document.addEventListener("visibilitychange", syncVisibility);
299
+ window.addEventListener("online", recover);
300
+ window.addEventListener("focus", recover);
301
+ };
302
+
303
+ const detachRecovery = () => {
304
+ if (!started || !browserAvailable()) return;
305
+ document.removeEventListener("visibilitychange", syncVisibility);
306
+ window.removeEventListener("online", recover);
307
+ window.removeEventListener("focus", recover);
308
+ };
309
+
243
310
  return {
244
311
  connect: () => {
245
312
  if (started || disposed || terminated) return;
@@ -248,7 +315,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
248
315
  fatal({ code: "unsupported", message: "Live WebSockets are not available in this browser" });
249
316
  return;
250
317
  }
251
- if (activity === "visible") document.addEventListener("visibilitychange", syncVisibility);
318
+ attachRecovery();
252
319
  openSocket();
253
320
  },
254
321
  markApplied,
@@ -258,9 +325,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
258
325
  if (disposed) return;
259
326
  disposed = true;
260
327
  clearReconnect();
261
- if (activity === "visible" && typeof document !== "undefined") {
262
- document.removeEventListener("visibilitychange", syncVisibility);
263
- }
328
+ detachRecovery();
264
329
  closeSocket();
265
330
  setStatus("closed");
266
331
  },
@@ -0,0 +1,202 @@
1
+ import type { MermaidConfig } from "mermaid";
2
+
3
+ /** Series colors shared by pie slices and mindmap branches. */
4
+ const lightSeries = [
5
+ "#dbeafe",
6
+ "#d1fae5",
7
+ "#fef3c7",
8
+ "#ede9fe",
9
+ "#ffe4e6",
10
+ "#cffafe",
11
+ "#ecfccb",
12
+ "#ffedd4",
13
+ "#e0f2fe",
14
+ "#ccfbf1",
15
+ "#fae8ff",
16
+ "#fef9c3",
17
+ ];
18
+ const darkSeries = [
19
+ "#1e3a8a",
20
+ "#065f46",
21
+ "#78350f",
22
+ "#4c1d95",
23
+ "#881337",
24
+ "#155e75",
25
+ "#3f6212",
26
+ "#7c2d12",
27
+ "#075985",
28
+ "#115e59",
29
+ "#86198f",
30
+ "#713f12",
31
+ ];
32
+
33
+ // Mermaid derives colors from hex values, so these mirror the Cloud tokens
34
+ // (zinc ink, --ui-surface*, --ui-border*) instead of reading CSS variables.
35
+ const light = {
36
+ darkMode: false,
37
+ background: "#ffffff",
38
+ textColor: "#27272a",
39
+ titleColor: "#18181b",
40
+ lineColor: "#71717a",
41
+ primaryColor: "#f4f4f5",
42
+ primaryTextColor: "#18181b",
43
+ primaryBorderColor: "#a1a1aa",
44
+ secondaryColor: "#eff6ff",
45
+ secondaryTextColor: "#18181b",
46
+ secondaryBorderColor: "#93c5fd",
47
+ tertiaryColor: "#fafafa",
48
+ tertiaryTextColor: "#18181b",
49
+ tertiaryBorderColor: "#d4d4d8",
50
+ mainBkg: "#f4f4f5",
51
+ nodeBorder: "#a1a1aa",
52
+ clusterBkg: "#fafafa",
53
+ clusterBorder: "#d4d4d8",
54
+ edgeLabelBackground: "#ffffff",
55
+ noteBkgColor: "#fefce8",
56
+ noteBorderColor: "#eab308",
57
+ noteTextColor: "#27272a",
58
+ actorBkg: "#f4f4f5",
59
+ actorBorder: "#a1a1aa",
60
+ actorTextColor: "#18181b",
61
+ signalColor: "#52525b",
62
+ signalTextColor: "#27272a",
63
+ labelBoxBkgColor: "#f4f4f5",
64
+ labelBoxBorderColor: "#a1a1aa",
65
+ activationBkgColor: "#e4e4e7",
66
+ activationBorderColor: "#a1a1aa",
67
+ attributeBackgroundColorOdd: "#ffffff",
68
+ attributeBackgroundColorEven: "#fafafa",
69
+ sectionBkgColor: "#f4f4f5",
70
+ altSectionBkgColor: "#ffffff",
71
+ sectionBkgColor2: "#fafafa",
72
+ gridColor: "#e4e4e7",
73
+ taskBkgColor: "#dbeafe",
74
+ taskBorderColor: "#3b82f6",
75
+ taskTextColor: "#18181b",
76
+ taskTextLightColor: "#18181b",
77
+ taskTextDarkColor: "#18181b",
78
+ taskTextOutsideColor: "#27272a",
79
+ activeTaskBkgColor: "#bfdbfe",
80
+ activeTaskBorderColor: "#2563eb",
81
+ doneTaskBkgColor: "#e4e4e7",
82
+ doneTaskBorderColor: "#a1a1aa",
83
+ critBkgColor: "#ffe4e6",
84
+ critBorderColor: "#e11d48",
85
+ todayLineColor: "#e11d48",
86
+ pieStrokeColor: "#ffffff",
87
+ pieOuterStrokeColor: "#d4d4d8",
88
+ pieSectionTextColor: "#18181b",
89
+ pieTitleTextColor: "#18181b",
90
+ pieLegendTextColor: "#27272a",
91
+ series: lightSeries,
92
+ seriesText: "#18181b",
93
+ };
94
+
95
+ const dark: typeof light = {
96
+ darkMode: true,
97
+ background: "#11151b",
98
+ textColor: "#e4e4e7",
99
+ titleColor: "#f4f4f5",
100
+ lineColor: "#a1a1aa",
101
+ primaryColor: "#20242a",
102
+ primaryTextColor: "#f4f4f5",
103
+ primaryBorderColor: "#5b6573",
104
+ secondaryColor: "#172554",
105
+ secondaryTextColor: "#f4f4f5",
106
+ secondaryBorderColor: "#3b82f6",
107
+ tertiaryColor: "#181d24",
108
+ tertiaryTextColor: "#f4f4f5",
109
+ tertiaryBorderColor: "#3a4552",
110
+ mainBkg: "#20242a",
111
+ nodeBorder: "#5b6573",
112
+ clusterBkg: "#181d24",
113
+ clusterBorder: "#3a4552",
114
+ edgeLabelBackground: "#11151b",
115
+ noteBkgColor: "#422006",
116
+ noteBorderColor: "#a16207",
117
+ noteTextColor: "#fef9c3",
118
+ actorBkg: "#20242a",
119
+ actorBorder: "#5b6573",
120
+ actorTextColor: "#f4f4f5",
121
+ signalColor: "#a1a1aa",
122
+ signalTextColor: "#e4e4e7",
123
+ labelBoxBkgColor: "#20242a",
124
+ labelBoxBorderColor: "#5b6573",
125
+ activationBkgColor: "#29313a",
126
+ activationBorderColor: "#5b6573",
127
+ attributeBackgroundColorOdd: "#181d24",
128
+ attributeBackgroundColorEven: "#20242a",
129
+ sectionBkgColor: "#181d24",
130
+ altSectionBkgColor: "#11151b",
131
+ sectionBkgColor2: "#1c2129",
132
+ gridColor: "#29313a",
133
+ taskBkgColor: "#1e3a8a",
134
+ taskBorderColor: "#60a5fa",
135
+ taskTextColor: "#f4f4f5",
136
+ taskTextLightColor: "#f4f4f5",
137
+ taskTextDarkColor: "#f4f4f5",
138
+ taskTextOutsideColor: "#e4e4e7",
139
+ activeTaskBkgColor: "#1e40af",
140
+ activeTaskBorderColor: "#93c5fd",
141
+ doneTaskBkgColor: "#3f3f46",
142
+ doneTaskBorderColor: "#71717a",
143
+ critBkgColor: "#881337",
144
+ critBorderColor: "#fb7185",
145
+ todayLineColor: "#fb7185",
146
+ pieStrokeColor: "#11151b",
147
+ pieOuterStrokeColor: "#3a4552",
148
+ pieSectionTextColor: "#f4f4f5",
149
+ pieTitleTextColor: "#f4f4f5",
150
+ pieLegendTextColor: "#e4e4e7",
151
+ series: darkSeries,
152
+ seriesText: "#f4f4f5",
153
+ };
154
+
155
+ /**
156
+ * Mermaid configuration that follows Cloud's light or dark tokens.
157
+ *
158
+ * Diagrams keep Mermaid 11's flat look and layouts; an author may still
159
+ * choose `layout: elk` in front matter, which loads ELK on demand. Author
160
+ * directives cannot change security, theme, or CSS.
161
+ */
162
+ export function mermaidConfig(options: { dark: boolean }): MermaidConfig {
163
+ const { series, seriesText, ...palette } = options.dark ? dark : light;
164
+ const themeVariables: Record<string, string | boolean> = {
165
+ ...palette,
166
+ fontFamily: '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif',
167
+ };
168
+ series.forEach((color, index) => {
169
+ themeVariables[`pie${index + 1}`] = color;
170
+ themeVariables[`cScale${index}`] = color;
171
+ themeVariables[`cScaleLabel${index}`] = seriesText;
172
+ });
173
+ return {
174
+ startOnLoad: false,
175
+ securityLevel: "strict",
176
+ suppressErrorRendering: true,
177
+ theme: "base",
178
+ look: "classic",
179
+ // Mermaid 12 lays these types out with ELK by default. Keep dagre so ELK
180
+ // loads only for diagrams whose front matter asks for `layout: elk`.
181
+ flowchart: { layout: "dagre" },
182
+ class: { layout: "dagre" },
183
+ state: { layout: "dagre" },
184
+ er: { layout: "dagre" },
185
+ requirement: { layout: "dagre" },
186
+ themeVariables,
187
+ secure: [
188
+ "secure",
189
+ "securityLevel",
190
+ "startOnLoad",
191
+ "maxTextSize",
192
+ "maxEdges",
193
+ "suppressErrorRendering",
194
+ "htmlLabels",
195
+ "themeCSS",
196
+ "themeVariables",
197
+ "theme",
198
+ "fontFamily",
199
+ "altFontFamily",
200
+ ],
201
+ };
202
+ }
@@ -831,7 +831,7 @@ export const accountLifecycle = {
831
831
  OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
832
832
  OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
833
833
  )
834
- ORDER BY deleted_at DESC
834
+ ORDER BY deleted_at DESC, id DESC
835
835
  LIMIT ${config.perPage}
836
836
  OFFSET ${offset}
837
837
  `;
@@ -905,7 +905,7 @@ export const accountLifecycle = {
905
905
  OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
906
906
  OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
907
907
  )
908
- ORDER BY r.created_at DESC
908
+ ORDER BY r.created_at DESC, r.id DESC
909
909
  LIMIT ${config.perPage}
910
910
  OFFSET ${offset}
911
911
  `;
@@ -1261,7 +1261,7 @@ export const accountsAppService = {
1261
1261
  FROM auth.account_requests r
1262
1262
  JOIN auth.users u ON u.id = r.user_id
1263
1263
  WHERE ${where}
1264
- ORDER BY r.created_at DESC
1264
+ ORDER BY r.created_at DESC, r.id DESC
1265
1265
  LIMIT ${perPage}
1266
1266
  OFFSET ${offset}
1267
1267
  `;
@@ -205,7 +205,7 @@ const list = async (
205
205
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
206
206
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
207
207
  AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
208
- ORDER BY created_at DESC
208
+ ORDER BY created_at DESC, id DESC
209
209
  LIMIT ${perPage} OFFSET ${offset}
210
210
  `;
211
211
  } else if (hasSourceList) {
@@ -225,7 +225,7 @@ const list = async (
225
225
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
226
226
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
227
227
  AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
228
- ORDER BY created_at DESC
228
+ ORDER BY created_at DESC, id DESC
229
229
  LIMIT ${perPage} OFFSET ${offset}
230
230
  `;
231
231
  } else {
@@ -243,7 +243,7 @@ const list = async (
243
243
  WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
244
244
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
245
245
  AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
246
- ORDER BY created_at DESC
246
+ ORDER BY created_at DESC, id DESC
247
247
  LIMIT ${perPage} OFFSET ${offset}
248
248
  `;
249
249
  }
@@ -789,7 +789,7 @@ const list = async (
789
789
  ) AS event_count
790
790
  FROM logging.trace_spans s
791
791
  WHERE ${where}
792
- ORDER BY COALESCE(s.ended_at, s.updated_at, s.started_at) DESC
792
+ ORDER BY COALESCE(s.ended_at, s.updated_at, s.started_at) DESC, s.trace_id DESC, s.span_id DESC
793
793
  LIMIT ${perPage} OFFSET ${offset}
794
794
  `,
795
795
  ]);
@@ -460,7 +460,7 @@ export const list = async (params?: {
460
460
  SELECT *, COUNT(*) OVER() AS total
461
461
  FROM notifications.batches
462
462
  WHERE (${params?.status ?? null}::text IS NULL OR status = ${params?.status ?? null})
463
- ORDER BY created_at DESC
463
+ ORDER BY created_at DESC, id DESC
464
464
  LIMIT ${perPage} OFFSET ${offset}
465
465
  `;
466
466
  return {
@@ -235,7 +235,7 @@ export const list = async (
235
235
  WHERE
236
236
  (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
237
237
  AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
238
- ORDER BY m.created_at DESC
238
+ ORDER BY m.created_at DESC, m.id DESC
239
239
  LIMIT ${perPage} OFFSET ${offset}
240
240
  `;
241
241
  } else {
@@ -251,7 +251,7 @@ export const list = async (
251
251
  FROM notifications.messages m
252
252
  LEFT JOIN auth.users u ON m.sent_by = u.id
253
253
  WHERE ${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
254
- ORDER BY m.created_at DESC
254
+ ORDER BY m.created_at DESC, m.id DESC
255
255
  LIMIT ${perPage} OFFSET ${offset}
256
256
  `;
257
257
  }
@@ -276,7 +276,7 @@ export const list = async (
276
276
  m.sent_by = ${sentBy}
277
277
  AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
278
278
  AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
279
- ORDER BY m.created_at DESC
279
+ ORDER BY m.created_at DESC, m.id DESC
280
280
  LIMIT ${perPage} OFFSET ${offset}
281
281
  `;
282
282
  } else {
@@ -296,7 +296,7 @@ export const list = async (
296
296
  WHERE
297
297
  m.sent_by = ${sentBy}
298
298
  AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
299
- ORDER BY m.created_at DESC
299
+ ORDER BY m.created_at DESC, m.id DESC
300
300
  LIMIT ${perPage} OFFSET ${offset}
301
301
  `;
302
302
  }
@@ -553,7 +553,7 @@ export const listOverview = async (config?: {
553
553
  OR sa.resource_type ILIKE '%' || ${search} || '%'
554
554
  OR sa.resource_id ILIKE '%' || ${search} || '%'
555
555
  )
556
- ORDER BY c.created_at DESC
556
+ ORDER BY c.created_at DESC, c.id DESC
557
557
  LIMIT ${perPage}
558
558
  OFFSET ${offset}
559
559
  `;
@@ -1,3 +1,5 @@
1
+ import { mermaidConfig } from "../../browser/mermaid";
2
+
1
3
  /**
2
4
  * Initialize Mermaid diagrams within a container element.
3
5
  * Call this in onMount() of your client-side component.
@@ -6,39 +8,7 @@
6
8
  */
7
9
  export async function initMermaid(container: HTMLElement): Promise<void> {
8
10
  const mermaid = (await import("mermaid")).default;
9
- // Initialize mermaid with theme detection
10
- const isDark = document.documentElement.classList.contains("dark");
11
- mermaid.initialize({
12
- startOnLoad: false,
13
- theme: "base",
14
- themeVariables: isDark
15
- ? {
16
- darkMode: true,
17
- background: "#09090b",
18
- textColor: "#e5e7eb",
19
- lineColor: "#6b7280",
20
- primaryTextColor: "#e5e7eb",
21
- secondaryTextColor: "#e5e7eb",
22
- tertiaryTextColor: "#e5e7eb",
23
- noteTextColor: "#e5e7eb",
24
- mainBkg: "#111827",
25
- secondBkg: "#1f2937",
26
- tertiaryColor: "#374151",
27
- }
28
- : {
29
- darkMode: false,
30
- background: "#ffffff",
31
- textColor: "#111827",
32
- lineColor: "#6b7280",
33
- primaryTextColor: "#111827",
34
- secondaryTextColor: "#111827",
35
- tertiaryTextColor: "#111827",
36
- noteTextColor: "#111827",
37
- mainBkg: "#ffffff",
38
- secondBkg: "#f9fafb",
39
- tertiaryColor: "#f3f4f6",
40
- },
41
- });
11
+ mermaid.initialize(mermaidConfig({ dark: document.documentElement.classList.contains("dark") }));
42
12
 
43
13
  // Find mermaid blocks (rendered with fixed-height container from server)
44
14
  const mermaidBlocks = container.querySelectorAll(".md-mermaid-block");