@stacksjs/defaults 0.74.2 → 0.74.4

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.
Files changed (40) hide show
  1. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  2. package/ai/skills/stacks-buddy/SKILL.md +53 -3
  3. package/ai/skills/stacks-cloud/SKILL.md +83 -11
  4. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  5. package/ai/skills/stacks-composables/SKILL.md +1 -1
  6. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  7. package/ai/skills/stacks-deploy/SKILL.md +97 -24
  8. package/ai/skills/stacks-orm/SKILL.md +1 -1
  9. package/ai/skills/stacks-types/SKILL.md +1 -1
  10. package/ai/skills/stacks-writing-for-agents/SKILL.md +1 -1
  11. package/app/Actions/Buddy/CommandsAction.ts +1 -10
  12. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +2 -7
  13. package/app/Actions/Dashboard/Analytics/web-analytics-provider.ts +103 -0
  14. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +24 -84
  15. package/app/Actions/Dashboard/Infrastructure/log-provider.ts +189 -0
  16. package/app/Actions/Dashboard/Marketing/AbandonedCartCampaignAction.ts +51 -0
  17. package/app/Actions/Dashboard/Marketing/AbandonedCartIndexAction.ts +85 -0
  18. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +316 -0
  19. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +429 -0
  20. package/app/Actions/Dashboard/dashboard-provider.ts +170 -0
  21. package/app/Actions/Monitoring/ErrorGroupAction.ts +2 -4
  22. package/app/Actions/Monitoring/ErrorIndexAction.ts +2 -4
  23. package/app/Actions/Monitoring/ErrorShowAction.ts +4 -4
  24. package/app/Actions/Monitoring/ErrorStatsAction.ts +2 -4
  25. package/app/Actions/Monitoring/ErrorTimelineAction.ts +2 -4
  26. package/app/Actions/Monitoring/error-provider.ts +130 -0
  27. package/app/Models/EmailIdempotency.ts +4 -1
  28. package/app/Models/EmailSuppression.ts +4 -1
  29. package/app/Models/EmailWebhookEvent.ts +4 -1
  30. package/app/Models/Request.ts +4 -1
  31. package/functions/api-url.test.ts +61 -0
  32. package/functions/api-url.ts +19 -2
  33. package/ide/vscode/package.json +1 -1
  34. package/package.json +2 -2
  35. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +240 -0
  36. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +96 -0
  37. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +145 -0
  38. package/resources/functions/dashboard/sidebar.ts +1 -0
  39. package/routes/dashboard-api.ts +5 -0
  40. package/views/dashboard/marketing/abandoned-carts/index.stx +10 -0
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Abandoned carts, and the campaign that goes after them.
3
+ *
4
+ * A cart that was filled and never checked out is the most qualified audience
5
+ * a shop has: somebody chose the products, priced them, and stopped. Every
6
+ * other campaign starts by guessing what a person wants; this one already
7
+ * knows, and the only question is whether anybody asks them to come back.
8
+ *
9
+ * The Campaign model already carries everything a recovery campaign needs -
10
+ * a channel, a schedule, delivery aggregates - so this does not add a second
11
+ * kind of campaign beside it. What makes a campaign a recovery campaign is
12
+ * its `segment_definition`: the column exists to say who a campaign is for,
13
+ * and a recovery campaign is one that says "the people whose carts went cold".
14
+ * Read `isRecoverySegment` as the whole of that contract.
15
+ *
16
+ * Nothing here writes. The functions are pure so the numbers on the dashboard
17
+ * can be tested without a database, which is the only way the attribution
18
+ * below is checkable at all.
19
+ */
20
+
21
+ export type AbandonedCartState = 'abandoned' | 'expired' | 'recovered'
22
+
23
+ /** The marker that makes a Campaign a cart-recovery campaign. */
24
+ export const ABANDONED_CART_TRIGGER = 'abandoned_cart'
25
+
26
+ /** How long a cart sits untouched before it is worth chasing, in hours. */
27
+ export const DEFAULT_IDLE_HOURS = 4
28
+
29
+ export interface AbandonedCartRecord {
30
+ id: string
31
+ customerId: string
32
+ customerName: string
33
+ customerEmail: string
34
+ itemCount: number
35
+ /** The first few product names, for a row that says what was left behind. */
36
+ items: string[]
37
+ value: number
38
+ currency: string
39
+ abandonedAt: string
40
+ /** Hours since the cart was last touched. */
41
+ idleHours: number
42
+ state: AbandonedCartState
43
+ /** Whether a recovery campaign has already written to this customer. */
44
+ contacted: boolean
45
+ contactedAt: string
46
+ }
47
+
48
+ export interface AbandonedCartSummary {
49
+ /** Carts sitting abandoned or expired right now. */
50
+ open: number
51
+ /** What those carts are worth. */
52
+ openValue: number
53
+ /** Of those, how many have already been written to. */
54
+ contacted: number
55
+ /** Carts that were contacted and then checked out. */
56
+ recovered: number
57
+ recoveredValue: number
58
+ /** Recovered as a share of everything that was ever chased. */
59
+ recoveryRate: number
60
+ averageValue: number
61
+ currency: string
62
+ }
63
+
64
+ export interface RecoveryCampaignRecord {
65
+ id: string
66
+ name: string
67
+ status: string
68
+ idleHours: number
69
+ minimumValue: number
70
+ sentCount: number
71
+ openedCount: number
72
+ clickedCount: number
73
+ scheduledAt: string
74
+ sentAt: string
75
+ }
76
+
77
+ export interface AbandonedCartIndexPayload {
78
+ records: AbandonedCartRecord[]
79
+ summary: AbandonedCartSummary
80
+ campaigns: RecoveryCampaignRecord[]
81
+ defaultCurrency: string
82
+ defaultIdleHours: number
83
+ }
84
+
85
+ function value(record: any, ...keys: string[]): unknown {
86
+ for (const key of keys) {
87
+ const result = typeof record?.get === 'function' ? record.get(key) : record?.[key]
88
+ if (result !== null && result !== undefined)
89
+ return result
90
+ }
91
+ return undefined
92
+ }
93
+
94
+ function text(input: unknown): string {
95
+ return input === null || input === undefined ? '' : String(input)
96
+ }
97
+
98
+ function number(input: unknown): number {
99
+ const result = Number(input)
100
+ return Number.isFinite(result) && result >= 0 ? result : 0
101
+ }
102
+
103
+ /**
104
+ * A timestamp as milliseconds, whichever way the driver spelled it.
105
+ *
106
+ * SQLite hands back `2026-09-01 18:04:00` and Postgres an ISO string; both
107
+ * have to compare against `Date.now()` or the idle hours on every row are
108
+ * NaN, and a NaN sorts first, which puts the least useful rows at the top.
109
+ *
110
+ * A stamp with no zone on it is read as UTC, because that is what wrote it:
111
+ * SQLite's CURRENT_TIMESTAMP is UTC and every driver here stores UTC. Left to
112
+ * `new Date()`, a bare `2026-09-01 18:04:00` is read as local instead, and a
113
+ * cart abandoned two hours ago in a UTC-7 browser comes back five hours in
114
+ * the future - which clamps to "idle 0h" and quietly empties every filter
115
+ * that asks for carts older than something.
116
+ */
117
+ function moment(input: unknown): number {
118
+ if (!input)
119
+ return Number.NaN
120
+ if (input instanceof Date)
121
+ return input.getTime()
122
+
123
+ const stamp = text(input).trim().replace(' ', 'T')
124
+ const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(stamp) ? stamp : `${stamp}Z`
125
+ const parsed = new Date(zoned).getTime()
126
+
127
+ return Number.isFinite(parsed) ? parsed : Number.NaN
128
+ }
129
+
130
+ function hoursBetween(from: number, to: number): number {
131
+ if (!Number.isFinite(from) || !Number.isFinite(to))
132
+ return 0
133
+ return Math.max(0, Math.round((to - from) / 36e5 * 10) / 10)
134
+ }
135
+
136
+ /**
137
+ * The audience a recovery campaign is written for.
138
+ *
139
+ * Stored on the campaign rather than recomputed from one, so a campaign that
140
+ * was sent last month still says what it was aimed at even after the carts it
141
+ * targeted have converted, expired, or been swept.
142
+ */
143
+ export function abandonedCartSegment(idleHours: number, minimumValue: number): {
144
+ trigger: string
145
+ operator: string
146
+ rules: Array<{ field: string, operator: string, value: unknown }>
147
+ } {
148
+ return {
149
+ trigger: ABANDONED_CART_TRIGGER,
150
+ operator: 'and',
151
+ rules: [
152
+ { field: 'cart.state', operator: 'is', value: 'abandoned' },
153
+ { field: 'cart.idleHours', operator: 'gte', value: Math.max(1, Math.round(idleHours) || DEFAULT_IDLE_HOURS) },
154
+ { field: 'cart.value', operator: 'gte', value: Math.max(0, minimumValue) },
155
+ ],
156
+ }
157
+ }
158
+
159
+ /** Whether a stored `segment_definition` describes a cart-recovery audience. */
160
+ export function isRecoverySegment(input: unknown): boolean {
161
+ const parsed = parseSegment(input)
162
+ return parsed?.trigger === ABANDONED_CART_TRIGGER
163
+ }
164
+
165
+ function parseSegment(input: unknown): Record<string, any> | null {
166
+ if (!input)
167
+ return null
168
+ if (typeof input === 'object')
169
+ return input as Record<string, any>
170
+ try {
171
+ const parsed = JSON.parse(text(input))
172
+ return parsed && typeof parsed === 'object' ? parsed : null
173
+ }
174
+ catch {
175
+ // A segment nobody can parse is a segment nobody can be sure of, and
176
+ // guessing here would silently widen who a campaign is sent to.
177
+ return null
178
+ }
179
+ }
180
+
181
+ function ruleValue(segment: Record<string, any> | null, field: string, fallback: number): number {
182
+ const rules = Array.isArray(segment?.rules) ? segment!.rules : []
183
+ const rule = rules.find((candidate: any) => candidate?.field === field)
184
+ const found = Number(rule?.value)
185
+ return Number.isFinite(found) ? found : fallback
186
+ }
187
+
188
+ function groupItems(itemRows: any[]): Map<string, { count: number, names: string[] }> {
189
+ const grouped = new Map<string, { count: number, names: string[] }>()
190
+ for (const row of itemRows) {
191
+ const cartId = text(value(row, 'cart_id', 'cartId'))
192
+ if (!cartId)
193
+ continue
194
+ const current = grouped.get(cartId) || { count: 0, names: [] }
195
+ current.count += Math.max(1, number(value(row, 'quantity')) || 1)
196
+ const name = text(value(row, 'product_name', 'productName'))
197
+ if (name && current.names.length < 4)
198
+ current.names.push(name)
199
+ grouped.set(cartId, current)
200
+ }
201
+ return grouped
202
+ }
203
+
204
+ /**
205
+ * Who a recovery campaign has already written to, and when it first did.
206
+ *
207
+ * Keyed by the address rather than by a subscriber id: a campaign send records
208
+ * the recipient it actually wrote to, and that address is the only thing a
209
+ * cart's customer and a campaign's send have in common.
210
+ */
211
+ function contactedRecipients(sendRows: any[], recoveryCampaignIds: Set<string>): Map<string, number> {
212
+ const contacted = new Map<string, number>()
213
+ for (const row of sendRows) {
214
+ if (!recoveryCampaignIds.has(text(value(row, 'campaign_id', 'campaignId'))))
215
+ continue
216
+ const recipient = text(value(row, 'recipient')).toLowerCase()
217
+ if (!recipient)
218
+ continue
219
+ const sentAt = moment(value(row, 'sent_at', 'sentAt') ?? value(row, 'created_at', 'createdAt'))
220
+ if (!Number.isFinite(sentAt))
221
+ continue
222
+ const existing = contacted.get(recipient)
223
+ if (existing === undefined || sentAt < existing)
224
+ contacted.set(recipient, sentAt)
225
+ }
226
+ return contacted
227
+ }
228
+
229
+ export function normalizeAbandonedCarts(
230
+ cartRows: any[],
231
+ itemRows: any[],
232
+ customerRows: any[],
233
+ campaignRows: any[],
234
+ sendRows: any[],
235
+ options: { defaultCurrency?: string, now?: Date, idleHours?: number } = {},
236
+ ): AbandonedCartIndexPayload {
237
+ const defaultCurrency = (options.defaultCurrency || 'USD').toUpperCase()
238
+ const now = (options.now ?? new Date()).getTime()
239
+ const defaultIdleHours = Math.max(1, Math.round(options.idleHours ?? DEFAULT_IDLE_HOURS))
240
+
241
+ const customers = new Map(customerRows.map(row => [text(value(row, 'id')), row]))
242
+ const items = groupItems(itemRows)
243
+
244
+ const campaigns = campaignRows
245
+ .filter(row => isRecoverySegment(value(row, 'segment_definition', 'segmentDefinition')))
246
+ .map((row): RecoveryCampaignRecord => {
247
+ const segment = parseSegment(value(row, 'segment_definition', 'segmentDefinition'))
248
+ return {
249
+ id: text(value(row, 'id')),
250
+ name: text(value(row, 'name')),
251
+ status: text(value(row, 'status')) || 'draft',
252
+ idleHours: ruleValue(segment, 'cart.idleHours', defaultIdleHours),
253
+ minimumValue: ruleValue(segment, 'cart.value', 0),
254
+ sentCount: number(value(row, 'sent_count', 'sentCount')),
255
+ openedCount: number(value(row, 'opened_count', 'openedCount')),
256
+ clickedCount: number(value(row, 'clicked_count', 'clickedCount')),
257
+ scheduledAt: text(value(row, 'scheduled_at', 'scheduledAt')),
258
+ sentAt: text(value(row, 'sent_at', 'sentAt')),
259
+ }
260
+ })
261
+
262
+ const contacted = contactedRecipients(sendRows, new Set(campaigns.map(campaign => campaign.id)))
263
+
264
+ const records = cartRows.map((cart): AbandonedCartRecord => {
265
+ const id = text(value(cart, 'id'))
266
+ const customerId = text(value(cart, 'customer_id', 'customerId'))
267
+ const customer = customers.get(customerId)
268
+ const email = text(value(customer, 'email'))
269
+ const status = text(value(cart, 'status')).toLowerCase()
270
+ const abandonedAtMs = moment(value(cart, 'updated_at', 'updatedAt') ?? value(cart, 'created_at', 'createdAt'))
271
+ const contactedAtMs = contacted.get(email.toLowerCase())
272
+ const grouped = items.get(id)
273
+
274
+ /*
275
+ * `converted` is only counted as recovered when a recovery campaign got
276
+ * there first. A cart somebody came back to on their own is a sale, not a
277
+ * campaign's, and crediting it would make every recovery campaign look
278
+ * like it worked.
279
+ */
280
+ const recovered = status === 'converted'
281
+ && contactedAtMs !== undefined
282
+ && Number.isFinite(abandonedAtMs)
283
+ && contactedAtMs <= abandonedAtMs
284
+
285
+ return {
286
+ id,
287
+ customerId,
288
+ customerName: text(value(customer, 'name')) || 'Guest',
289
+ customerEmail: email,
290
+ itemCount: grouped?.count || number(value(cart, 'total_items', 'totalItems')),
291
+ items: grouped?.names || [],
292
+ value: number(value(cart, 'total')),
293
+ currency: text(value(cart, 'currency')).toUpperCase() || defaultCurrency,
294
+ abandonedAt: text(value(cart, 'updated_at', 'updatedAt') ?? value(cart, 'created_at', 'createdAt')),
295
+ idleHours: hoursBetween(abandonedAtMs, now),
296
+ state: recovered ? 'recovered' : status === 'expired' ? 'expired' : 'abandoned',
297
+ contacted: contactedAtMs !== undefined,
298
+ contactedAt: contactedAtMs === undefined ? '' : new Date(contactedAtMs).toISOString(),
299
+ }
300
+ })
301
+
302
+ const open = records.filter(record => record.state !== 'recovered')
303
+ const recovered = records.filter(record => record.state === 'recovered')
304
+ const openValue = open.reduce((sum, record) => sum + record.value, 0)
305
+ const recoveredValue = recovered.reduce((sum, record) => sum + record.value, 0)
306
+ const chased = open.filter(record => record.contacted).length + recovered.length
307
+
308
+ return {
309
+ records,
310
+ summary: {
311
+ open: open.length,
312
+ openValue,
313
+ contacted: open.filter(record => record.contacted).length,
314
+ recovered: recovered.length,
315
+ recoveredValue,
316
+ recoveryRate: chased > 0 ? recovered.length / chased * 100 : 0,
317
+ averageValue: open.length > 0 ? openValue / open.length : 0,
318
+ currency: records[0]?.currency || defaultCurrency,
319
+ },
320
+ campaigns,
321
+ defaultCurrency,
322
+ defaultIdleHours,
323
+ }
324
+ }
325
+
326
+ /**
327
+ * How many carts a campaign with these rules would be written for, and what
328
+ * they are worth. Shown on the compose dialog so nobody schedules a send to
329
+ * nobody, or to everybody.
330
+ */
331
+ export function reachOf(
332
+ records: AbandonedCartRecord[],
333
+ idleHours: number,
334
+ minimumValue: number,
335
+ ): { carts: number, value: number } {
336
+ const matching = records.filter(record =>
337
+ record.state === 'abandoned'
338
+ && record.customerEmail !== ''
339
+ && record.idleHours >= idleHours
340
+ && record.value >= minimumValue)
341
+
342
+ return {
343
+ carts: matching.length,
344
+ value: matching.reduce((sum, record) => sum + record.value, 0),
345
+ }
346
+ }
347
+
348
+ export interface RecoveryCampaignInput {
349
+ name: string
350
+ subject: string
351
+ template: string
352
+ text: string
353
+ fromName: string
354
+ fromAddress: string
355
+ emailListId: number | null
356
+ idleHours: number
357
+ minimumValue: number
358
+ scheduledAt: string | null
359
+ currency: string
360
+ }
361
+
362
+ export function recoveryCampaignWriteData(
363
+ input: Record<string, unknown>,
364
+ defaultCurrency = 'USD',
365
+ ): {
366
+ name: string
367
+ description: string
368
+ type: 'email'
369
+ status: 'draft' | 'scheduled'
370
+ subject: string
371
+ template: string
372
+ text: string | null
373
+ from_name: string | null
374
+ from_address: string | null
375
+ email_list_id: number | null
376
+ segment_definition: string
377
+ scheduled_at: string | null
378
+ currency: string
379
+ } {
380
+ const idleHours = Math.max(1, Math.round(Number(input.idleHours)) || DEFAULT_IDLE_HOURS)
381
+ const minimumValue = Math.max(0, Number(input.minimumValue) || 0)
382
+ const emailListId = Number(input.emailListId ?? input.email_list_id)
383
+ const scheduledAt = text(input.scheduledAt ?? input.scheduled_at).trim()
384
+
385
+ return {
386
+ name: text(input.name).trim(),
387
+ description: `Recovery campaign for carts idle ${idleHours}h or more.`,
388
+ type: 'email',
389
+ status: scheduledAt ? 'scheduled' : 'draft',
390
+ subject: text(input.subject).trim(),
391
+ template: text(input.template).trim() || 'abandoned-cart',
392
+ text: text(input.text).trim() || null,
393
+ from_name: text(input.fromName ?? input.from_name).trim() || null,
394
+ from_address: text(input.fromAddress ?? input.from_address).trim() || null,
395
+ email_list_id: Number.isInteger(emailListId) && emailListId > 0 ? emailListId : null,
396
+ segment_definition: JSON.stringify(abandonedCartSegment(idleHours, minimumValue)),
397
+ scheduled_at: scheduledAt || null,
398
+ currency: text(input.currency).trim().toUpperCase() || defaultCurrency,
399
+ }
400
+ }
401
+
402
+ export function validateRecoveryCampaign(
403
+ data: ReturnType<typeof recoveryCampaignWriteData>,
404
+ now = new Date(),
405
+ ): string {
406
+ if (data.name.trim().length < 3)
407
+ return 'Recovery campaign names must contain at least 3 characters.'
408
+ if (!data.subject)
409
+ return 'A recovery campaign needs a subject line.'
410
+
411
+ /*
412
+ * The whole point of this campaign is that it writes to people who left a
413
+ * cart, and the address it writes to comes from the cart's customer. A
414
+ * campaign aimed at an email list would write to the list instead, which is
415
+ * a newsletter with a misleading name.
416
+ */
417
+ if (data.email_list_id)
418
+ return 'Recovery campaigns take their audience from abandoned carts, not from an email list.'
419
+
420
+ if (data.status === 'scheduled') {
421
+ const scheduledAt = new Date(String(data.scheduled_at).replace(' ', 'T')).getTime()
422
+ if (!Number.isFinite(scheduledAt))
423
+ return 'Enter a valid send time.'
424
+ if (scheduledAt <= now.getTime())
425
+ return 'A recovery campaign has to be scheduled for the future.'
426
+ }
427
+
428
+ return ''
429
+ }
@@ -0,0 +1,170 @@
1
+ import type { DashboardHqDriverOptions, DashboardProviderName, DashboardProviderSelection } from '@stacksjs/types'
2
+
3
+ /**
4
+ * The seam between a dashboard section and the data behind it.
5
+ *
6
+ * Every section in the dashboard used to reach for a local store directly:
7
+ * the Logs page queried the `logs` table, the Errors pages called into
8
+ * `@stacksjs/commerce`, and web Analytics aggregated the `Request` model. That
9
+ * works exactly once, for the application the dashboard is running inside. It
10
+ * cannot show the logs of a fleet, and it gave the hosted products (loghq,
11
+ * bughq, analyticshq) nowhere to plug in.
12
+ *
13
+ * This module resolves which provider a domain reads from. `local` is the
14
+ * default and is byte-for-byte the behaviour that shipped before the seam
15
+ * existed, including how it fails: a local provider is never wrapped, so a
16
+ * database outage still surfaces the same operational error it always did.
17
+ * Anything else is remote, and remote is wrapped, because a section whose
18
+ * backend is unreachable has to render empty with a reason rather than take
19
+ * the page down.
20
+ *
21
+ * Read paths only. No provider writes.
22
+ */
23
+
24
+ /** The dashboard sections that resolve a provider. */
25
+ export type DashboardProviderDomain = 'logs' | 'errors' | 'analytics'
26
+
27
+ const PROVIDER_NAMES: readonly DashboardProviderName[] = ['local', 'hq']
28
+
29
+ /**
30
+ * Why a section has no data.
31
+ *
32
+ * Carried on the payload beside the empty collections rather than thrown, so
33
+ * the section renders its own empty state instead of an error page. The string
34
+ * is written for whoever is looking at the dashboard, not for a log file.
35
+ */
36
+ export interface DashboardProviderUnavailable {
37
+ unavailable: string
38
+ }
39
+
40
+ export interface ResolvedDashboardDriver {
41
+ name: DashboardProviderName
42
+ options: DashboardHqDriverOptions
43
+ }
44
+
45
+ const resolved = new Map<DashboardProviderDomain, ResolvedDashboardDriver>()
46
+
47
+ /**
48
+ * Forgets every memoized driver choice.
49
+ *
50
+ * Resolution is cached per domain so a request does not re-read config on
51
+ * every query. Tests that walk more than one configuration in a single process
52
+ * need that cache cleared between cases, and clearing it is much cheaper than
53
+ * `mock.module`, which replaces the shared module instance for every other
54
+ * test file in the run and is not undone by `mock.restore()`.
55
+ */
56
+ export function resetDashboardProviders(): void {
57
+ resolved.clear()
58
+ }
59
+
60
+ function readSelection(section: unknown, domain: DashboardProviderDomain): DashboardProviderSelection {
61
+ if (!section || typeof section !== 'object')
62
+ return {}
63
+ const providers = (section as { providers?: unknown }).providers
64
+ if (!providers || typeof providers !== 'object')
65
+ return {}
66
+ const selection = (providers as Record<string, unknown>)[domain]
67
+ return selection && typeof selection === 'object' ? selection as DashboardProviderSelection : {}
68
+ }
69
+
70
+ function selectDriverName(value: unknown, domain: DashboardProviderDomain): DashboardProviderName {
71
+ if (value === undefined || value === null || value === '')
72
+ return 'local'
73
+
74
+ switch (value) {
75
+ case 'local':
76
+ return 'local'
77
+ case 'hq':
78
+ return 'hq'
79
+ default:
80
+ // `satisfies DashboardConfig` is erased at runtime, and config files are
81
+ // ordinary TypeScript an app can hand-edit, so an unknown name reaches
82
+ // here as a plain string. Falling back to local keeps the section
83
+ // rendering; throwing would take it down over a typo.
84
+ console.warn(
85
+ `[dashboard/api] config.dashboard.providers.${domain}.driver is `
86
+ + `"${String(value)}", which is not a known provider. Reading local data instead. `
87
+ + `Available providers: ${PROVIDER_NAMES.join(', ')}.`,
88
+ )
89
+ return 'local'
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Reads a domain's provider choice from config.
95
+ *
96
+ * Config is read through `overridesReady`. A read that happens before the
97
+ * application's own config files have been layered in returns the framework
98
+ * defaults with no error at all, which would silently resolve `local` in an
99
+ * application that had asked for something else.
100
+ */
101
+ export async function resolveDashboardDriver(domain: DashboardProviderDomain): Promise<ResolvedDashboardDriver> {
102
+ const cached = resolved.get(domain)
103
+ if (cached)
104
+ return cached
105
+
106
+ let selection: DashboardProviderSelection = {}
107
+ try {
108
+ const { config, overridesReady } = await import('@stacksjs/config')
109
+ // Rejection means boot could not read the application's config at all.
110
+ // The framework defaults are still a usable answer, and they are local.
111
+ await overridesReady.catch(() => {})
112
+ selection = readSelection(config.dashboard, domain)
113
+ }
114
+ catch (error) {
115
+ console.warn(`[dashboard/api] dashboard provider config for ${domain} could not be read, using local:`, error)
116
+ }
117
+
118
+ const driver: ResolvedDashboardDriver = {
119
+ name: selectDriverName(selection.driver, domain),
120
+ options: selection.drivers?.hq ?? {},
121
+ }
122
+
123
+ resolved.set(domain, driver)
124
+ return driver
125
+ }
126
+
127
+ /**
128
+ * Runs a remote provider so that it cannot take a section down.
129
+ *
130
+ * Three ways a remote read goes wrong, all of which land on the same empty
131
+ * payload plus a reason: it rejects, it never answers, or it answers with
132
+ * something that is not the shape the section renders. The last one matters
133
+ * as much as the first two, because a payload that is merely missing its
134
+ * array reaches the component and fails there instead, which is a blank page
135
+ * rather than an empty state.
136
+ */
137
+ export async function readThroughProvider<T>(
138
+ domain: DashboardProviderDomain,
139
+ empty: (reason: string) => T,
140
+ read: () => Promise<T>,
141
+ isValid: (payload: unknown) => boolean,
142
+ ): Promise<T> {
143
+ try {
144
+ const payload = await read()
145
+ if (!isValid(payload)) {
146
+ console.error(`[dashboard/api] the ${domain} provider answered with an unexpected shape`)
147
+ return empty('This section could not be read from its provider.')
148
+ }
149
+ return payload
150
+ }
151
+ catch (error) {
152
+ console.error(`[dashboard/api] the ${domain} provider could not be read:`, error)
153
+ return empty('This section could not be reached. It is configured to read from a hosted provider.')
154
+ }
155
+ }
156
+
157
+ /**
158
+ * The reason an `hq` provider has no data yet.
159
+ *
160
+ * Both published SDKs are ingest clients. `@loghq/stacks` and `@bughq/stacks`
161
+ * send records and expose no function that reads any back, and a project's
162
+ * ingest key grants no read access by design. The hosted read APIs do exist,
163
+ * but they authenticate a person rather than a machine, so wiring one needs a
164
+ * product decision about service credentials before any code here can help.
165
+ *
166
+ * Selecting `hq` today therefore yields an empty section that says so, which
167
+ * is the honest answer and keeps the degrade path real rather than theoretical.
168
+ */
169
+ export const HQ_READ_UNAVAILABLE
170
+ = 'No data. The hosted provider has no read API yet, so this section cannot be filled from it.'
@@ -1,6 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { errors } from '@stacksjs/commerce'
3
2
  import { response } from '@stacksjs/router'
3
+ import { readErrorsByGroup } from './error-provider'
4
4
 
5
5
  export default new Action({
6
6
  name: 'Error Group',
@@ -14,8 +14,6 @@ export default new Action({
14
14
  return response.json({ error: 'Both type and message are required' }, 400)
15
15
  }
16
16
 
17
- const results = await errors.fetchByGroup(type, message)
18
-
19
- return response.json({ data: results })
17
+ return response.json(await readErrorsByGroup(type, message))
20
18
  },
21
19
  })
@@ -1,14 +1,12 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { errors } from '@stacksjs/commerce'
3
2
  import { response } from '@stacksjs/router'
3
+ import { readGroupedErrors } from './error-provider'
4
4
 
5
5
  export default new Action({
6
6
  name: 'Error Index',
7
7
  description: 'Fetch all grouped errors (Sentry-like aggregation)',
8
8
  method: 'GET',
9
9
  async handle() {
10
- const results = await errors.fetchGrouped()
11
-
12
- return response.json({ data: results })
10
+ return response.json(await readGroupedErrors())
13
11
  },
14
12
  })
@@ -1,6 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { errors } from '@stacksjs/commerce'
3
2
  import { response } from '@stacksjs/router'
3
+ import { readErrorById } from './error-provider'
4
4
 
5
5
  export default new Action({
6
6
  name: 'Error Show',
@@ -13,12 +13,12 @@ export default new Action({
13
13
  if (id === null)
14
14
  return response.json({ error: 'A numeric error id is required' }, 422)
15
15
 
16
- const result = await errors.fetchById(id)
16
+ const result = await readErrorById(id)
17
17
 
18
- if (!result) {
18
+ if (!result.data) {
19
19
  return response.json({ error: 'Error not found' }, 404)
20
20
  }
21
21
 
22
- return response.json({ data: result })
22
+ return response.json(result)
23
23
  },
24
24
  })
@@ -1,14 +1,12 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { errors } from '@stacksjs/commerce'
3
2
  import { response } from '@stacksjs/router'
3
+ import { readErrorStats } from './error-provider'
4
4
 
5
5
  export default new Action({
6
6
  name: 'Error Stats',
7
7
  description: 'Fetch error statistics',
8
8
  method: 'GET',
9
9
  async handle() {
10
- const stats = await errors.fetchStats()
11
-
12
- return response.json({ data: stats })
10
+ return response.json(await readErrorStats())
13
11
  },
14
12
  })
@@ -1,14 +1,12 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { errors } from '@stacksjs/commerce'
3
2
  import { response } from '@stacksjs/router'
3
+ import { readErrorTimeline } from './error-provider'
4
4
 
5
5
  export default new Action({
6
6
  name: 'Error Timeline',
7
7
  description: 'Fetch error timeline (hourly counts for last 24 hours)',
8
8
  method: 'GET',
9
9
  async handle() {
10
- const timeline = await errors.fetchTimeline()
11
-
12
- return response.json({ data: timeline })
10
+ return response.json(await readErrorTimeline())
13
11
  },
14
12
  })