@stacksjs/defaults 0.74.3 → 0.74.5
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/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-buddy/SKILL.md +53 -3
- package/ai/skills/stacks-cloud/SKILL.md +83 -11
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-composables/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-deploy/SKILL.md +97 -24
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/ai/skills/stacks-types/SKILL.md +1 -1
- package/ai/skills/stacks-writing-for-agents/SKILL.md +1 -1
- package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +2 -7
- package/app/Actions/Dashboard/Analytics/web-analytics-provider.ts +103 -0
- package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +24 -84
- package/app/Actions/Dashboard/Infrastructure/log-provider.ts +189 -0
- package/app/Actions/Dashboard/dashboard-provider.ts +170 -0
- package/app/Actions/Monitoring/ErrorGroupAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorIndexAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorShowAction.ts +4 -4
- package/app/Actions/Monitoring/ErrorStatsAction.ts +2 -4
- package/app/Actions/Monitoring/ErrorTimelineAction.ts +2 -4
- package/app/Actions/Monitoring/error-provider.ts +130 -0
- package/app/Models/EmailIdempotency.ts +4 -1
- package/app/Models/EmailSuppression.ts +4 -1
- package/app/Models/EmailWebhookEvent.ts +4 -1
- package/app/Models/Request.ts +4 -1
- package/functions/api-url.test.ts +61 -0
- package/functions/api-url.ts +19 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/app/Actions/Buddy/CommandsAction.ts +0 -820
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { DashboardProviderUnavailable } from '../Dashboard/dashboard-provider'
|
|
2
|
+
import { errors } from '@stacksjs/commerce'
|
|
3
|
+
import { HQ_READ_UNAVAILABLE, readThroughProvider, resolveDashboardDriver } from '../Dashboard/dashboard-provider'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the Errors section reads from.
|
|
7
|
+
*
|
|
8
|
+
* Every one of these actions used to call `@stacksjs/commerce` directly, which
|
|
9
|
+
* is how error tracking came to be served by the shopping package: the store
|
|
10
|
+
* lives there for historical reasons and nine dashboard actions grew a
|
|
11
|
+
* dependency on it. Routing the reads through one provider puts that coupling
|
|
12
|
+
* in a single file, so replacing the store later is a change here rather than
|
|
13
|
+
* a change in nine actions.
|
|
14
|
+
*
|
|
15
|
+
* Read paths only. Resolving, ignoring, unresolving and deleting an error stay
|
|
16
|
+
* exactly where they were and always act on the local store. A section reading
|
|
17
|
+
* from a remote provider while writing to a local one would silently disagree
|
|
18
|
+
* with itself, so the write actions are deliberately left untouched until a
|
|
19
|
+
* provider exists that can actually accept a write.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The record shapes the error store deals in.
|
|
24
|
+
*
|
|
25
|
+
* Derived from the store's own functions rather than imported, because
|
|
26
|
+
* `@stacksjs/commerce` exposes its errors module as a value and keeps these
|
|
27
|
+
* interfaces inside it. Deriving them means the provider contract follows the
|
|
28
|
+
* store automatically, and it keeps this refactor out of the commerce package.
|
|
29
|
+
*/
|
|
30
|
+
export type DashboardErrorRecord = NonNullable<Awaited<ReturnType<typeof errors.fetchById>>>
|
|
31
|
+
export type DashboardGroupedError = Awaited<ReturnType<typeof errors.fetchGrouped>>[number]
|
|
32
|
+
export type DashboardErrorStats = Awaited<ReturnType<typeof errors.fetchStats>>
|
|
33
|
+
|
|
34
|
+
export interface DashboardErrorTimelinePoint {
|
|
35
|
+
hour: string
|
|
36
|
+
count: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DashboardErrorsProvider {
|
|
40
|
+
grouped: () => Promise<DashboardGroupedError[]>
|
|
41
|
+
stats: () => Promise<DashboardErrorStats>
|
|
42
|
+
timeline: () => Promise<DashboardErrorTimelinePoint[]>
|
|
43
|
+
byGroup: (type: string, message: string) => Promise<DashboardErrorRecord[]>
|
|
44
|
+
byId: (id: number) => Promise<DashboardErrorRecord | undefined>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Wraps a payload with the reason its section is empty. */
|
|
48
|
+
export type DashboardErrorsEnvelope<T> = { data: T } & Partial<DashboardProviderUnavailable>
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* This application's own error store.
|
|
52
|
+
*
|
|
53
|
+
* Not wrapped in a catch. These actions have never caught a store failure, and
|
|
54
|
+
* adding one here would convert today's error response into a misleading empty
|
|
55
|
+
* list, which is a behaviour change rather than the refactor this is meant to be.
|
|
56
|
+
*/
|
|
57
|
+
export const localErrorsProvider: DashboardErrorsProvider = {
|
|
58
|
+
grouped: () => errors.fetchGrouped(),
|
|
59
|
+
stats: () => errors.fetchStats(),
|
|
60
|
+
timeline: () => errors.fetchTimeline(),
|
|
61
|
+
byGroup: (type, message) => errors.fetchByGroup(type, message),
|
|
62
|
+
byId: id => errors.fetchById(id),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The empty answer for each read, used when a remote provider cannot be reached.
|
|
67
|
+
*
|
|
68
|
+
* `stats` has no natural empty value, so it reports zeros. A section showing
|
|
69
|
+
* zeros next to its reason is clearer than one showing nothing at all.
|
|
70
|
+
*/
|
|
71
|
+
const EMPTY_STATS: DashboardErrorStats = {
|
|
72
|
+
total: 0,
|
|
73
|
+
unresolved: 0,
|
|
74
|
+
resolved: 0,
|
|
75
|
+
ignored: 0,
|
|
76
|
+
last_24h: 0,
|
|
77
|
+
trend: 0,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveErrorsProvider(): Promise<{ provider: DashboardErrorsProvider, remote: boolean }> {
|
|
81
|
+
const driver = await resolveDashboardDriver('errors')
|
|
82
|
+
return { provider: localErrorsProvider, remote: driver.name !== 'local' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Runs one error read through the configured provider.
|
|
87
|
+
*
|
|
88
|
+
* A local read is handed straight back, failures included. A remote read is
|
|
89
|
+
* guarded, so an unreachable provider yields the empty value plus a reason
|
|
90
|
+
* rather than taking the section down.
|
|
91
|
+
*/
|
|
92
|
+
async function readErrors<T>(
|
|
93
|
+
empty: T,
|
|
94
|
+
read: (provider: DashboardErrorsProvider) => Promise<T>,
|
|
95
|
+
isValid: (payload: unknown) => boolean,
|
|
96
|
+
): Promise<DashboardErrorsEnvelope<T>> {
|
|
97
|
+
const { provider, remote } = await resolveErrorsProvider()
|
|
98
|
+
|
|
99
|
+
if (!remote)
|
|
100
|
+
return { data: await read(provider) }
|
|
101
|
+
|
|
102
|
+
const payload = await readThroughProvider<DashboardErrorsEnvelope<T>>(
|
|
103
|
+
'errors',
|
|
104
|
+
reason => ({ data: empty, unavailable: reason }),
|
|
105
|
+
async () => ({ data: empty, unavailable: HQ_READ_UNAVAILABLE }),
|
|
106
|
+
candidate => isValid((candidate as DashboardErrorsEnvelope<T>)?.data),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return payload
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function readGroupedErrors(): Promise<DashboardErrorsEnvelope<DashboardGroupedError[]>> {
|
|
113
|
+
return readErrors([], provider => provider.grouped(), Array.isArray)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function readErrorStats(): Promise<DashboardErrorsEnvelope<DashboardErrorStats>> {
|
|
117
|
+
return readErrors(EMPTY_STATS, provider => provider.stats(), payload => Boolean(payload) && typeof payload === 'object')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readErrorTimeline(): Promise<DashboardErrorsEnvelope<DashboardErrorTimelinePoint[]>> {
|
|
121
|
+
return readErrors([], provider => provider.timeline(), Array.isArray)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function readErrorsByGroup(type: string, message: string): Promise<DashboardErrorsEnvelope<DashboardErrorRecord[]>> {
|
|
125
|
+
return readErrors([], provider => provider.byGroup(type, message), Array.isArray)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readErrorById(id: number): Promise<DashboardErrorsEnvelope<DashboardErrorRecord | undefined>> {
|
|
129
|
+
return readErrors<DashboardErrorRecord | undefined>(undefined, provider => provider.byId(id), () => true)
|
|
130
|
+
}
|
|
@@ -17,7 +17,10 @@ export default defineModel({
|
|
|
17
17
|
useApi: {
|
|
18
18
|
uri: 'email-idempotency',
|
|
19
19
|
routes: ['index', 'show', 'destroy'],
|
|
20
|
-
|
|
20
|
+
// Reads stay as they were; writes need an admin.
|
|
21
|
+
// idempotency keys are what stop a retry from sending twice,
|
|
22
|
+
// so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
|
|
23
|
+
middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
|
|
21
24
|
},
|
|
22
25
|
},
|
|
23
26
|
|
|
@@ -25,7 +25,10 @@ export default defineModel({
|
|
|
25
25
|
useApi: {
|
|
26
26
|
uri: 'email-suppressions',
|
|
27
27
|
routes: ['index', 'show', 'destroy'],
|
|
28
|
-
|
|
28
|
+
// Reads stay as they were; writes need an admin.
|
|
29
|
+
// a suppression entry is a compliance record - deleting one re-enables mail to someone who bounced or opted out,
|
|
30
|
+
// so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
|
|
31
|
+
middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
|
|
29
32
|
},
|
|
30
33
|
},
|
|
31
34
|
|
|
@@ -25,7 +25,10 @@ export default defineModel({
|
|
|
25
25
|
useApi: {
|
|
26
26
|
uri: 'email-webhook-events',
|
|
27
27
|
routes: ['index', 'show', 'destroy'],
|
|
28
|
-
|
|
28
|
+
// Reads stay as they were; writes need an admin.
|
|
29
|
+
// provider webhook events are the audit trail for what the provider told us,
|
|
30
|
+
// so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
|
|
31
|
+
middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
|
|
29
32
|
},
|
|
30
33
|
},
|
|
31
34
|
|
package/app/Models/Request.ts
CHANGED
|
@@ -36,7 +36,10 @@ export default defineModel({
|
|
|
36
36
|
useApi: {
|
|
37
37
|
uri: 'requests',
|
|
38
38
|
routes: ['index', 'store', 'show', 'update', 'destroy'],
|
|
39
|
-
|
|
39
|
+
// Reads stay as they were; writes need an admin.
|
|
40
|
+
// request logs are an audit trail, and carry whatever the request carried,
|
|
41
|
+
// so `auth` alone let any signed-in caller do it (stacksjs/stacks#2412).
|
|
42
|
+
middleware: { read: ['auth'], write: ['auth', 'role:admin'] },
|
|
40
43
|
},
|
|
41
44
|
},
|
|
42
45
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveApiBaseUrl` must survive a `window` that is not a DOM.
|
|
3
|
+
*
|
|
4
|
+
* It guarded on `typeof window !== 'undefined'` and then read
|
|
5
|
+
* `window.location.origin`. Test harnesses assign `globalThis.window =
|
|
6
|
+
* globalThis` so browser code can be imported off-DOM, and that object has no
|
|
7
|
+
* `location` - so the guard passed and the next property read threw.
|
|
8
|
+
*
|
|
9
|
+
* Callers resolve this at MODULE scope (`monitoring/errors.ts` builds its
|
|
10
|
+
* `baseURL` there), so the throw happened on import: an unhandled error
|
|
11
|
+
* between tests, attributed to no test, which is why it survived a full-suite
|
|
12
|
+
* cleanup that fixed everything with a name (stacksjs/stacks#2421).
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
15
|
+
import { resolveApiBaseUrl } from './api-url'
|
|
16
|
+
|
|
17
|
+
const REAL = {
|
|
18
|
+
window: (globalThis as any).window,
|
|
19
|
+
configured: (globalThis as any).__STACKS_API_URL__,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const [key, value] of Object.entries({ window: REAL.window, __STACKS_API_URL__: REAL.configured })) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
delete (globalThis as any)[key]
|
|
26
|
+
else
|
|
27
|
+
(globalThis as any)[key] = value
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('resolveApiBaseUrl', () => {
|
|
32
|
+
it('falls back to the relative path when window has no location', () => {
|
|
33
|
+
// Exactly what a harness leaves behind: a window that is not a DOM.
|
|
34
|
+
;(globalThis as any).window = globalThis
|
|
35
|
+
delete (globalThis as any).__STACKS_API_URL__
|
|
36
|
+
|
|
37
|
+
expect(resolveApiBaseUrl()).toBe('/api')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses the origin when there is a real one', () => {
|
|
41
|
+
;(globalThis as any).window = { location: { origin: 'https://app.test' } }
|
|
42
|
+
delete (globalThis as any).__STACKS_API_URL__
|
|
43
|
+
|
|
44
|
+
expect(resolveApiBaseUrl()).toBe('https://app.test/api')
|
|
45
|
+
expect(resolveApiBaseUrl('/v1')).toBe('https://app.test/v1')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('prefers an injected URL over the origin, without its trailing slashes', () => {
|
|
49
|
+
;(globalThis as any).window = { location: { origin: 'https://app.test' } }
|
|
50
|
+
;(globalThis as any).__STACKS_API_URL__ = 'https://api.test//'
|
|
51
|
+
|
|
52
|
+
expect(resolveApiBaseUrl()).toBe('https://api.test')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('falls back to the relative path off-DOM entirely', () => {
|
|
56
|
+
delete (globalThis as any).window
|
|
57
|
+
delete (globalThis as any).__STACKS_API_URL__
|
|
58
|
+
|
|
59
|
+
expect(resolveApiBaseUrl()).toBe('/api')
|
|
60
|
+
})
|
|
61
|
+
})
|
package/functions/api-url.ts
CHANGED
|
@@ -15,8 +15,25 @@ export function resolveApiBaseUrl(defaultPath = '/api'): string {
|
|
|
15
15
|
if (configured)
|
|
16
16
|
return configured.replace(/\/+$/, '')
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
/*
|
|
19
|
+
* `window.location`, not just `window`.
|
|
20
|
+
*
|
|
21
|
+
* A global named `window` is not proof of a DOM. Test harnesses assign
|
|
22
|
+
* `globalThis.window = globalThis` to make browser code importable off-DOM,
|
|
23
|
+
* and that object has no `location` - so the guard passed and the very next
|
|
24
|
+
* property read threw `undefined is not an object`. Callers compute this at
|
|
25
|
+
* MODULE scope (`monitoring/errors.ts` builds its `baseURL` there), so the
|
|
26
|
+
* throw happened on import, before any code could catch it: it surfaced as an
|
|
27
|
+
* unhandled error between tests, attributed to no test at all
|
|
28
|
+
* (stacksjs/stacks#2421).
|
|
29
|
+
*
|
|
30
|
+
* Reading the origin defensively costs nothing in a real browser and keeps
|
|
31
|
+
* this resolvable anywhere - a worker, SSR, a partial DOM shim - where the
|
|
32
|
+
* honest answer is the relative default.
|
|
33
|
+
*/
|
|
34
|
+
const origin = typeof window !== 'undefined' ? window.location?.origin : undefined
|
|
35
|
+
if (origin)
|
|
36
|
+
return `${origin}${defaultPath}`
|
|
20
37
|
|
|
21
38
|
return defaultPath
|
|
22
39
|
}
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.5",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@iconify-json/f7": "^1.2.2",
|
|
56
56
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
57
|
-
"@stacksjs/mobile": "^0.74.
|
|
57
|
+
"@stacksjs/mobile": "^0.74.5",
|
|
58
58
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
59
59
|
"ts-qr-codes": "^0.1.8"
|
|
60
60
|
}
|