@stacksjs/defaults 0.74.45 → 0.74.47
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-commerce/SKILL.md +2 -2
- package/ai/skills/stacks-composables/SKILL.md +48 -44
- package/ai/skills/stacks-database/SKILL.md +5 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/app/Actions/Auth/LogoutAction.ts +8 -5
- package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +38 -11
- package/app/Controllers/QueryController.ts +110 -48
- package/app/Models/Content/Categorizable.ts +18 -0
- package/app/Models/commerce/Category.ts +6 -15
- package/ide/vscode/package.json +2 -2
- package/package.json +2 -2
- package/routes/dashboard-api.ts +10 -2
- package/routes/dashboard.ts +5 -3
|
@@ -122,9 +122,9 @@ if (!result.ok) {
|
|
|
122
122
|
// result.coupon reflects the post-redemption state.
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
`redeem` bumps `usage_count` and enforces `
|
|
125
|
+
`redeem` bumps `usage_count` and enforces `usage_limit`, `is_active` and the
|
|
126
126
|
start/end dates in the WHERE clause, so the database decides the race. A
|
|
127
|
-
`
|
|
127
|
+
`usage_limit` of `NULL` means unlimited. Do not fetch, check and then call
|
|
128
128
|
`update()` to increment: that is the exact pattern this replaced.
|
|
129
129
|
|
|
130
130
|
### Spending or reloading a gift card
|
|
@@ -8,54 +8,56 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
8
8
|
|
|
9
9
|
# Stacks Composables
|
|
10
10
|
|
|
11
|
-
154 reactive composables for STX templates. **
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
`
|
|
39
|
-
`useDateFormat`, `useFetch`, `useFloor`, `useForm`, `useGitStore`, `useMax`,
|
|
40
|
-
`useMin`, `useNow`, `useOnline`, `usePaymentStore`, `usePrecision`,
|
|
41
|
-
`usePreferredDark`, `useQueueStore`, `useRound`, `useScrollLock`, `useStorage`,
|
|
42
|
-
`useSum`, `useTimeoutFn`, `useToggle`, `useTrunc`, `useUserStore`.
|
|
11
|
+
154 reactive composables for STX templates. **A fixed set of them is available
|
|
12
|
+
bare**, listed below; everything else needs an explicit import from
|
|
13
|
+
`@stacksjs/composables`.
|
|
14
|
+
|
|
15
|
+
The stx runtime decides this, not `browser-auto-imports.json`. That manifest
|
|
16
|
+
feeds an ambient `.d.ts` and nothing reads it at build time, so it says what the
|
|
17
|
+
compiler accepts and not what the browser has; the two disagree in both
|
|
18
|
+
directions (stacksjs/stacks#2585).
|
|
19
|
+
|
|
20
|
+
This page said "All are auto-imported in STX templates", which is the mistake
|
|
21
|
+
`AGENTS.md` carries a scar about under "200+ composables": an agent reaching for
|
|
22
|
+
a name on that authority writes a template that does not run, and reads the
|
|
23
|
+
failure as a framework bug.
|
|
24
|
+
|
|
25
|
+
## What you can write bare in a template
|
|
26
|
+
|
|
27
|
+
<!-- auto-imported:begin - checked against the stx runtime by
|
|
28
|
+
core/composables/tests/skill-runtime-globals.test.ts. These are the names
|
|
29
|
+
`getCachedSignalsRuntime()` attaches to `window`, which is what decides
|
|
30
|
+
whether a bare call resolves in a template. Do not derive this list from
|
|
31
|
+
`browser-auto-imports.json`: that manifest is compile-time only, and 22 of
|
|
32
|
+
the 27 `use*` it declares are absent from the runtime. -->
|
|
33
|
+
|
|
34
|
+
`useAsync`, `useClickOutside`, `useColorMode`, `useCounter`, `useDark`,
|
|
35
|
+
`useDebounce`, `useDebouncedValue`, `useEventListener`, `useFetch`, `useFocus`,
|
|
36
|
+
`useHead`, `useInterval`, `useLocalStorage`, `useMutation`, `useQuery`,
|
|
37
|
+
`useRef`, `useRoute`, `useSearchParams`, `useSeoMeta`, `useSessionStorage`,
|
|
38
|
+
`useStore`, `useThrottle`, `useTimeout`, `useToggle`, `useWebSocket`.
|
|
43
39
|
|
|
44
40
|
<!-- auto-imported:end -->
|
|
45
41
|
|
|
46
|
-
|
|
42
|
+
Everything else needs an explicit import, and that is most of what the sections
|
|
43
|
+
below list:
|
|
47
44
|
|
|
48
45
|
```ts
|
|
49
|
-
import {
|
|
46
|
+
import { useStorage } from '@stacksjs/composables'
|
|
50
47
|
```
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
**`buddy typecheck` will not tell you which is which, and currently disagrees
|
|
50
|
+
with the browser in both directions** (stacksjs/stacks#2585).
|
|
51
|
+
`storage/framework/browser-auto-imports.json` feeds an ambient `.d.ts`, so the
|
|
52
|
+
compiler accepts every name it declares - and only five of its 27 `use*` are in
|
|
53
|
+
the runtime. `useStorage`, `useNow`, `useDateFormat`, `useForm` and the `use*Store`
|
|
54
|
+
composables typecheck and then throw a ReferenceError during setup, which takes
|
|
55
|
+
the page down rather than failing the one call. In the other direction
|
|
56
|
+
`useLocalStorage`, `useColorMode`, `useCounter` and `useMediaQuery` all work in
|
|
57
|
+
a template and `tsc` rejects them.
|
|
58
|
+
|
|
59
|
+
The list above is the runtime's, so it is the one that predicts whether the page
|
|
60
|
+
loads.
|
|
59
61
|
|
|
60
62
|
## Key Path
|
|
61
63
|
- Core package: `storage/framework/core/composables/src/`
|
|
@@ -200,9 +202,11 @@ isRef(val) // type guard
|
|
|
200
202
|
- `and`, `or`, `logicNot`, `logicOr`
|
|
201
203
|
|
|
202
204
|
## Gotchas
|
|
203
|
-
- Only
|
|
204
|
-
`
|
|
205
|
-
|
|
205
|
+
- Only the names listed above are available bare in an STX template, and they
|
|
206
|
+
come from the stx runtime, not from `browser-auto-imports.json` - that
|
|
207
|
+
manifest is compile-time only and disagrees with the runtime in both
|
|
208
|
+
directions (stacksjs/stacks#2585). Everything else needs
|
|
209
|
+
`import { … } from '@stacksjs/composables'`
|
|
206
210
|
- NEVER use vanilla JS (`var`, `document.*`, `window.*`) in STX `<script>` tags
|
|
207
211
|
- Only use stx-compatible code: signals, composables, directives
|
|
208
212
|
- Auto-imports defined in `storage/framework/browser-auto-imports.json`
|
|
@@ -12,7 +12,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
12
12
|
- Database package: `storage/framework/core/database/src/`
|
|
13
13
|
- Configuration: `config/database.ts`
|
|
14
14
|
- QB config: `config/query-builder.ts`
|
|
15
|
-
- Migrations: `database/migrations/` (
|
|
15
|
+
- Migrations: `database/migrations/` (229 migration files, `.sql` format)
|
|
16
16
|
- QB state: `.qb/`
|
|
17
17
|
- ORM: `storage/framework/orm/`
|
|
18
18
|
|
|
@@ -262,6 +262,10 @@ Entity-centric API for single-table design:
|
|
|
262
262
|
// skips query hooks unless persistent history is explicitly enabled.
|
|
263
263
|
enabled: env.DB_QUERY_LOGGING_ENABLED ?? !['production', 'prod'].includes(env.APP_ENV || ''),
|
|
264
264
|
captureAllTraces: false, // slow and failed queries always keep traces
|
|
265
|
+
// Bound values in query_logs.bindings, credentials stored as `<redacted>`
|
|
266
|
+
// (by name and shape, so not every secret); production keeps only each
|
|
267
|
+
// value's type unless this is enabled. Env takes true/false, 1/0, yes/no, on/off.
|
|
268
|
+
captureBindings: env.DB_QUERY_LOGGING_CAPTURE_BINDINGS ?? !['production', 'prod'].includes(env.APP_ENV || ''),
|
|
265
269
|
slowThreshold: 100, // ms
|
|
266
270
|
retention: 7, // days
|
|
267
271
|
pruneFrequency: 24, // hours
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-migrations
|
|
3
|
-
description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the
|
|
3
|
+
description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 229 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -119,6 +119,6 @@ The framework includes migrations for all built-in models:
|
|
|
119
119
|
- Do not commit a second snapshot under `.qb/`; that indicates a missing `snapshotDir` configuration
|
|
120
120
|
- If a generated SQLite migration rebuilds tables, test it against a copy of the current database and run `PRAGMA integrity_check` plus `PRAGMA foreign_key_check`
|
|
121
121
|
- `--seed` flag after `migrate:fresh` seeds the database with factory data
|
|
122
|
-
-
|
|
122
|
+
- 229 migration files exist by default for all framework models
|
|
123
123
|
- SQLite >= 3.47.2 is required (system requirement)
|
|
124
124
|
- For the database API (queries, connections), see the `stacks-database` skill
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import { Auth, clearAuthCookie } from '@stacksjs/auth'
|
|
3
|
-
import { response } from '@stacksjs/router'
|
|
2
|
+
import { Auth, clearAuthCookie, requestToken } from '@stacksjs/auth'
|
|
3
|
+
import { getCurrentRequest, response } from '@stacksjs/router'
|
|
4
4
|
|
|
5
5
|
export default new Action({
|
|
6
6
|
name: 'LogoutAction',
|
|
7
7
|
description: 'Logout from the application',
|
|
8
8
|
method: 'POST',
|
|
9
9
|
async handle() {
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const request = getCurrentRequest()
|
|
11
|
+
const usesDatabaseSession = !requestToken(request) && !!request?.cookie?.('session_id')
|
|
12
12
|
await Auth.logout()
|
|
13
13
|
|
|
14
14
|
// Clearing is separate from revoking, and both are needed. Revoking alone
|
|
15
15
|
// leaves the browser sending a dead cookie on every request; clearing alone
|
|
16
16
|
// leaves a copied cookie valid for the token's whole lifetime, which would
|
|
17
17
|
// make "log out on a shared computer" mean only "hide the key".
|
|
18
|
-
|
|
18
|
+
const result = response.json(
|
|
19
19
|
{ message: 'Successfully logged out' },
|
|
20
20
|
{ headers: { 'Set-Cookie': clearAuthCookie() } },
|
|
21
21
|
)
|
|
22
|
+
if (usesDatabaseSession)
|
|
23
|
+
result.headers.append('Set-Cookie', clearAuthCookie({ name: 'session_id' }))
|
|
24
|
+
return result
|
|
22
25
|
},
|
|
23
26
|
})
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { RequestInstance } from '@stacksjs/types'
|
|
2
2
|
import { Action } from '@stacksjs/actions'
|
|
3
|
-
import { db } from '@stacksjs/database'
|
|
3
|
+
import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
|
-
import {
|
|
5
|
+
import { teamOperationalError } from '../../Teams/team-response'
|
|
6
6
|
import { syncTeamMemberCount } from './team-member-count'
|
|
7
|
-
import {
|
|
7
|
+
import { normalizeInvitationRole, parsePositiveId, sqlTimestamp } from './team-records'
|
|
8
8
|
|
|
9
9
|
interface UpdateInput {
|
|
10
10
|
role?: unknown
|
|
@@ -27,12 +27,41 @@ export default new Action({
|
|
|
27
27
|
try {
|
|
28
28
|
return await db.transaction(async (rawTrx) => {
|
|
29
29
|
const trx = rawTrx as unknown as typeof db
|
|
30
|
-
|
|
30
|
+
/*
|
|
31
|
+
* Lock the member for the rest of this transaction, then decide.
|
|
32
|
+
*
|
|
33
|
+
* This used to read the row unlocked and let the UPDATE's own
|
|
34
|
+
* `role != 'owner'` predicate catch a concurrent ownership transfer,
|
|
35
|
+
* treating an affected-row count other than 1 as "changed underneath
|
|
36
|
+
* us" and answering 409. That count cannot carry the decision: MySQL
|
|
37
|
+
* reports rows CHANGED, not rows MATCHED, so saving a member with the
|
|
38
|
+
* role and status they already have reported 0, and a double submit
|
|
39
|
+
* or any save in the same second as their last write (`sqlTimestamp`
|
|
40
|
+
* has second precision) came back 409 "The team member changed before
|
|
41
|
+
* they could be updated" for a member nobody had touched
|
|
42
|
+
* (stacksjs/stacks#2639).
|
|
43
|
+
*
|
|
44
|
+
* With the row locked, nothing can change it between this read and
|
|
45
|
+
* the write, so the checks below are made against the state the write
|
|
46
|
+
* will actually land on, and the count is not needed at all. A
|
|
47
|
+
* concurrent ownership transfer now waits for this transaction and
|
|
48
|
+
* then applies, instead of racing it. Measured on PostgreSQL 16 and
|
|
49
|
+
* MySQL 8.4.5: unlocked, a second connection made the member an owner
|
|
50
|
+
* mid-transaction; locked, it hit its lock timeout.
|
|
51
|
+
*
|
|
52
|
+
* SQLite has no `FOR UPDATE` and does not need one: a writer that
|
|
53
|
+
* commits after this read makes the UPDATE below fail when it tries to
|
|
54
|
+
* upgrade the transaction, so nothing is silently overwritten there
|
|
55
|
+
* either. auth/src/tokens.ts takes the same lock the same way.
|
|
56
|
+
*/
|
|
57
|
+
let lookup = trx
|
|
31
58
|
.selectFrom('team_members')
|
|
32
59
|
.where('id', '=', memberId)
|
|
33
60
|
.where('team_id', '=', teamId)
|
|
34
61
|
.select(['id', 'role', 'status'])
|
|
35
|
-
|
|
62
|
+
if (!sqlHelpers(getDatabaseDialect()).isSqlite)
|
|
63
|
+
lookup = lookup.lockForUpdate()
|
|
64
|
+
const member = await lookup.executeTakeFirst()
|
|
36
65
|
if (!member)
|
|
37
66
|
return response.json({ message: 'Team member not found.' }, 404)
|
|
38
67
|
if (member.role === 'owner')
|
|
@@ -45,23 +74,21 @@ export default new Action({
|
|
|
45
74
|
if (!['active', 'suspended'].includes(status))
|
|
46
75
|
return response.json({ message: 'Choose a valid member status.' }, 422)
|
|
47
76
|
|
|
48
|
-
|
|
77
|
+
// The owner predicate stays as a second line of defence; under the
|
|
78
|
+
// lock it always matches, which is why its count is not read.
|
|
79
|
+
await trx
|
|
49
80
|
.updateTable('team_members')
|
|
50
81
|
.set({ role, status, updated_at: sqlTimestamp() })
|
|
51
82
|
.where('id', '=', memberId)
|
|
52
83
|
.where('team_id', '=', teamId)
|
|
53
84
|
.where('role', '!=', 'owner')
|
|
54
|
-
.
|
|
55
|
-
if (changedRows(updated) !== 1)
|
|
56
|
-
throw new TeamStateConflictError('The team member changed before they could be updated.')
|
|
85
|
+
.execute()
|
|
57
86
|
|
|
58
87
|
await syncTeamMemberCount(teamId, trx)
|
|
59
88
|
return { member: { id: memberId, role, status } }
|
|
60
89
|
})
|
|
61
90
|
}
|
|
62
91
|
catch (error) {
|
|
63
|
-
if (error instanceof TeamStateConflictError)
|
|
64
|
-
return response.json({ message: error.message }, 409)
|
|
65
92
|
return teamOperationalError(error, 'The team member could not be updated.', 'TeamMemberUpdateAction', 500)
|
|
66
93
|
}
|
|
67
94
|
},
|
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
import { config } from '@stacksjs/config'
|
|
2
|
-
import { db, sql } from '@stacksjs/database'
|
|
2
|
+
import { db, mutationCount, sql, sqlDateTime } from '@stacksjs/database'
|
|
3
3
|
import { Controller } from '@stacksjs/server'
|
|
4
4
|
|
|
5
|
+
const DAY_MS = 86_400_000
|
|
6
|
+
/** `2026-09-10T11` - the stored timestamp truncated to its hour. */
|
|
7
|
+
const HOUR_PREFIX = 13
|
|
8
|
+
/** `2026-09-10` - truncated to its day. */
|
|
9
|
+
const DAY_PREFIX = 10
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The hour bucket as the dashboard reads it: `2026-09-10 11:00:00`.
|
|
13
|
+
*
|
|
14
|
+
* Buckets are prefixes of the stored timestamp rather than the output of a
|
|
15
|
+
* date function, because `strftime` and `datetime` exist only on SQLite. The
|
|
16
|
+
* stored format is fixed (see `sqlDateTime`), so a prefix is exact, and the
|
|
17
|
+
* label is restored here rather than in SQL.
|
|
18
|
+
*/
|
|
19
|
+
function hourLabel(bucket: string): string {
|
|
20
|
+
return `${String(bucket).replace('T', ' ')}:00:00`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The query type is the first element of the JSON array in `tags`, written by
|
|
25
|
+
* the query logger. Read here rather than in SQL: the JSON functions differ
|
|
26
|
+
* per dialect and PostgreSQL has none by these names.
|
|
27
|
+
*/
|
|
28
|
+
function queryTypeFromTags(tags: string | null | undefined): string {
|
|
29
|
+
if (!tags)
|
|
30
|
+
return 'unknown'
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(tags) as unknown
|
|
33
|
+
const first = Array.isArray(parsed) ? parsed[0] : undefined
|
|
34
|
+
return typeof first === 'string' && first ? first : 'unknown'
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'unknown'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
5
41
|
export default class QueryController extends Controller {
|
|
6
42
|
/**
|
|
7
43
|
* Get query statistics for the dashboard
|
|
@@ -14,15 +50,30 @@ export default class QueryController extends Controller {
|
|
|
14
50
|
.select(db.fn.count('id').as('count'))
|
|
15
51
|
.executeTakeFirstOrThrow()
|
|
16
52
|
|
|
17
|
-
//
|
|
18
|
-
|
|
53
|
+
// `tags` is a string column holding a JSON array whose first element is
|
|
54
|
+
// the query type. Grouping by the whole column keeps this portable:
|
|
55
|
+
// `json_extract` is SQLite and MySQL only, and PostgreSQL has no such
|
|
56
|
+
// function, so the previous projection made this endpoint fail there.
|
|
57
|
+
// The distinct tag sets are few, so folding them by type here is cheap.
|
|
58
|
+
const tagStats = await db
|
|
19
59
|
.selectFrom('query_logs')
|
|
20
60
|
.select([
|
|
21
|
-
|
|
61
|
+
'tags',
|
|
22
62
|
db.fn.count('id').as('count'),
|
|
63
|
+
db.fn.avg('duration').as('avg_duration'),
|
|
23
64
|
])
|
|
24
|
-
.groupBy(
|
|
25
|
-
.execute()
|
|
65
|
+
.groupBy('tags')
|
|
66
|
+
.execute() as Array<{ tags: string | null, count: number | string, avg_duration: number | string | null }>
|
|
67
|
+
|
|
68
|
+
const byType = new Map<string, { count: number, durationTotal: number }>()
|
|
69
|
+
for (const row of tagStats) {
|
|
70
|
+
const type = queryTypeFromTags(row.tags)
|
|
71
|
+
const count = Number(row.count) || 0
|
|
72
|
+
const entry = byType.get(type) ?? { count: 0, durationTotal: 0 }
|
|
73
|
+
entry.count += count
|
|
74
|
+
entry.durationTotal += Number(row.avg_duration ?? 0) * count
|
|
75
|
+
byType.set(type, entry)
|
|
76
|
+
}
|
|
26
77
|
|
|
27
78
|
// Get counts by status
|
|
28
79
|
const statusStats = await db
|
|
@@ -31,35 +82,31 @@ export default class QueryController extends Controller {
|
|
|
31
82
|
.groupBy('status')
|
|
32
83
|
.execute()
|
|
33
84
|
|
|
34
|
-
// Get average duration by query type
|
|
35
|
-
const durationStats = await db
|
|
36
|
-
.selectFrom('query_logs')
|
|
37
|
-
.select([
|
|
38
|
-
sql`json_extract(tags, "$[0]")`.as('type'),
|
|
39
|
-
db.fn.avg('duration').as('avg_duration'),
|
|
40
|
-
])
|
|
41
|
-
.groupBy(sql`json_extract(tags, "$[0]")`)
|
|
42
|
-
.execute()
|
|
43
|
-
|
|
44
85
|
// Get count of slow queries over time (last 24 hours)
|
|
45
86
|
const slowQueriesTimeline = await db
|
|
46
87
|
.selectFrom('query_logs')
|
|
47
88
|
.select([
|
|
48
|
-
sql`
|
|
89
|
+
sql`substr(executed_at, 1, 13)`.as('hour'),
|
|
49
90
|
db.fn.count('id').as('count'),
|
|
50
91
|
])
|
|
51
92
|
.where('status', '=', 'slow')
|
|
52
|
-
.
|
|
53
|
-
.groupBy(
|
|
93
|
+
.where('executed_at', '>=', sqlDateTime(new Date(Date.now() - 86_400_000)))
|
|
94
|
+
.groupBy('hour')
|
|
54
95
|
.orderBy('hour')
|
|
55
|
-
.execute()
|
|
96
|
+
.execute() as Array<{ hour: string, count: number | string }>
|
|
56
97
|
|
|
57
98
|
return {
|
|
58
99
|
totalQueries: totalQueries.count,
|
|
59
|
-
byType:
|
|
100
|
+
byType: [...byType].map(([type, entry]) => ({ type, count: entry.count })),
|
|
60
101
|
byStatus: statusStats,
|
|
61
|
-
avgDuration:
|
|
62
|
-
|
|
102
|
+
avgDuration: [...byType].map(([type, entry]) => ({
|
|
103
|
+
type,
|
|
104
|
+
avg_duration: entry.count === 0 ? 0 : entry.durationTotal / entry.count,
|
|
105
|
+
})),
|
|
106
|
+
slowQueriesTimeline: slowQueriesTimeline.map(row => ({
|
|
107
|
+
hour: hourLabel(row.hour),
|
|
108
|
+
count: Number(row.count) || 0,
|
|
109
|
+
})),
|
|
63
110
|
// Include system settings for reference
|
|
64
111
|
settings: {
|
|
65
112
|
slowThreshold: config.database?.queryLogging?.slowThreshold || 100,
|
|
@@ -278,38 +325,43 @@ export default class QueryController extends Controller {
|
|
|
278
325
|
type = 'all',
|
|
279
326
|
}) {
|
|
280
327
|
try {
|
|
281
|
-
let
|
|
282
|
-
let
|
|
328
|
+
let bucketWidth: number
|
|
329
|
+
let windowMs: number
|
|
283
330
|
|
|
284
|
-
// Set
|
|
331
|
+
// Set the bucket width and how far back to look, per timeframe.
|
|
285
332
|
switch (timeframe) {
|
|
286
333
|
case 'week':
|
|
287
|
-
|
|
288
|
-
|
|
334
|
+
bucketWidth = DAY_PREFIX
|
|
335
|
+
windowMs = 7 * DAY_MS
|
|
289
336
|
break
|
|
290
337
|
case 'month':
|
|
291
|
-
|
|
292
|
-
|
|
338
|
+
bucketWidth = DAY_PREFIX
|
|
339
|
+
windowMs = 30 * DAY_MS
|
|
293
340
|
break
|
|
294
341
|
case 'day':
|
|
295
342
|
default:
|
|
296
|
-
|
|
297
|
-
|
|
343
|
+
bucketWidth = HOUR_PREFIX
|
|
344
|
+
windowMs = DAY_MS
|
|
298
345
|
break
|
|
299
346
|
}
|
|
300
347
|
|
|
301
348
|
let query = db
|
|
302
349
|
.selectFrom('query_logs')
|
|
303
350
|
.select([
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
351
|
+
// Timestamps are stored in one canonical format, so the bucket is a
|
|
352
|
+
// prefix of the stored text: 13 characters for an hour, 10 for a
|
|
353
|
+
// day. `strftime` and `datetime` are SQLite-only, and the value in
|
|
354
|
+
// `sql`...`` was interpolated as a quoted `'${interval}'`, which the
|
|
355
|
+
// tag rendered as a literal `'?'`, so this returned nothing at all,
|
|
356
|
+
// on every dialect.
|
|
357
|
+
// Two fixed fragments rather than one interpolated width: a value
|
|
358
|
+
// inside the tag is bound as a parameter, and a parameter here
|
|
359
|
+
// throws the builder's own count off.
|
|
360
|
+
(bucketWidth === HOUR_PREFIX ? sql`substr(executed_at, 1, 13)` : sql`substr(executed_at, 1, 10)`).as('time_interval'),
|
|
309
361
|
db.fn.count('id').as('count'),
|
|
310
362
|
db.fn.avg('duration').as('avg_duration'),
|
|
311
363
|
])
|
|
312
|
-
.
|
|
364
|
+
.where('executed_at', '>=', sqlDateTime(new Date(Date.now() - windowMs)))
|
|
313
365
|
.groupBy('time_interval')
|
|
314
366
|
.orderBy('time_interval')
|
|
315
367
|
|
|
@@ -317,10 +369,13 @@ export default class QueryController extends Controller {
|
|
|
317
369
|
if (type !== 'all')
|
|
318
370
|
query = query.where('tags', 'like', `%"${type}"%`)
|
|
319
371
|
|
|
320
|
-
const results = await query.execute()
|
|
372
|
+
const results = await query.execute() as Array<{ time_interval: string, count: number | string, avg_duration: number | string | null }>
|
|
321
373
|
|
|
322
374
|
return {
|
|
323
|
-
data: results
|
|
375
|
+
data: results.map(row => ({
|
|
376
|
+
...row,
|
|
377
|
+
time_interval: bucketWidth === HOUR_PREFIX ? hourLabel(row.time_interval) : row.time_interval,
|
|
378
|
+
})),
|
|
324
379
|
meta: {
|
|
325
380
|
timeframe,
|
|
326
381
|
type,
|
|
@@ -397,16 +452,23 @@ export default class QueryController extends Controller {
|
|
|
397
452
|
try {
|
|
398
453
|
const retentionDays = config.database?.queryLogging?.retention || 7
|
|
399
454
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
455
|
+
// The cutoff is computed here rather than in SQL. `datetime("now", ?)`
|
|
456
|
+
// is SQLite syntax, so this statement was a syntax error on PostgreSQL
|
|
457
|
+
// and MySQL and pruned nothing there. It was not right on SQLite either:
|
|
458
|
+
// `datetime()` renders `2026-09-10 11:00:00` while the logger writes
|
|
459
|
+
// `sqlDateTime()`'s `2026-09-10T11:00:00.000`, and comparing those as
|
|
460
|
+
// text keeps rows up to a day past their retention, because 'T' > ' '.
|
|
461
|
+
// A bound cutoff in the stored format compares correctly everywhere.
|
|
462
|
+
const cutoff = sqlDateTime(new Date(Date.now() - retentionDays * 86_400_000))
|
|
463
|
+
const result = await db
|
|
464
|
+
.deleteFrom('query_logs')
|
|
465
|
+
.where('executed_at', '<', cutoff)
|
|
466
|
+
.executeTakeFirst()
|
|
407
467
|
|
|
408
468
|
return {
|
|
409
|
-
|
|
469
|
+
// Each driver names the affected-row count differently, and the pair
|
|
470
|
+
// read here covered only SQLite.
|
|
471
|
+
pruned: mutationCount(result),
|
|
410
472
|
retentionDays,
|
|
411
473
|
}
|
|
412
474
|
}
|
|
@@ -14,6 +14,24 @@ export default defineModel({
|
|
|
14
14
|
{ name: 'categorizables_type_slug_unique', columns: ['categorizable_type', 'slug'], unique: true },
|
|
15
15
|
{ name: 'categorizables_owner_slug_unique', columns: ['categorizable_type', 'categorizable_id', 'slug'], unique: true },
|
|
16
16
|
],
|
|
17
|
+
// The inverse of Post.belongsToMany.categories, mirroring Tag.belongsToMany.posts.
|
|
18
|
+
// It lives here because this model owns the ids `category_id` stores, and the
|
|
19
|
+
// declaring model's table is what the generator references for `foreignKey`.
|
|
20
|
+
belongsToMany: {
|
|
21
|
+
posts: {
|
|
22
|
+
model: 'Post',
|
|
23
|
+
table: 'categorizable_models',
|
|
24
|
+
foreignKey: 'category_id',
|
|
25
|
+
relatedKey: 'categorizable_id',
|
|
26
|
+
pivot: {
|
|
27
|
+
columns: {
|
|
28
|
+
categorizable_type: { default: 'posts' },
|
|
29
|
+
},
|
|
30
|
+
timestamps: true,
|
|
31
|
+
uniques: [['category_id', 'categorizable_id', 'categorizable_type']],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
17
35
|
attributes: {
|
|
18
36
|
name: {
|
|
19
37
|
required: true,
|
|
@@ -41,21 +41,12 @@ export default defineModel({
|
|
|
41
41
|
},
|
|
42
42
|
|
|
43
43
|
hasMany: ['Product'],
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
pivot: {
|
|
51
|
-
columns: {
|
|
52
|
-
categorizable_type: { default: 'posts' },
|
|
53
|
-
},
|
|
54
|
-
timestamps: true,
|
|
55
|
-
uniques: [['category_id', 'categorizable_id', 'categorizable_type']],
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
},
|
|
44
|
+
|
|
45
|
+
// No `belongsToMany.posts` here. Post categories live in the CMS pivot
|
|
46
|
+
// `categorizable_models`, whose `category_id` holds `categorizables` ids, so
|
|
47
|
+
// the inverse belongs on Categorizable. Declaring it on this model made the
|
|
48
|
+
// generator emit `category_id REFERENCES "categories"`, which rejected every
|
|
49
|
+
// CMS category link once foreign keys were enforced (stacksjs/stacks#2593).
|
|
59
50
|
|
|
60
51
|
attributes: {
|
|
61
52
|
name: {
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.47",
|
|
6
6
|
"description": "A modern Stacks development environment.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/chrisbbreuer",
|
|
@@ -74,6 +74,6 @@
|
|
|
74
74
|
"streetsidesoftware.code-spell-checker"
|
|
75
75
|
],
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@vscode/vsce": "^
|
|
77
|
+
"@vscode/vsce": "^4.0.0"
|
|
78
78
|
}
|
|
79
79
|
}
|
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.47",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
58
|
-
"@stacksjs/mobile": "^0.74.
|
|
58
|
+
"@stacksjs/mobile": "^0.74.47",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
package/routes/dashboard-api.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* unauthenticated.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { isLocalDeployment } from '@stacksjs/env'
|
|
19
20
|
import { route } from '@stacksjs/router'
|
|
20
21
|
|
|
21
22
|
// The `/api/dashboard/*` surface is unauthenticated by design for the local
|
|
@@ -25,8 +26,15 @@ import { route } from '@stacksjs/router'
|
|
|
25
26
|
// (assign-any-role-to-any-user = privilege escalation) and the model-row dump
|
|
26
27
|
// (arbitrary DB read) — must be gated server-side. In a local/dev/test env the
|
|
27
28
|
// guard is a no-op so the dev dashboard keeps working without a token.
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
//
|
|
30
|
+
// The gate is the deployment, not the environment NAME. `.env.example` ships
|
|
31
|
+
// `APP_ENV=development`, so every app that never edited that line called
|
|
32
|
+
// itself development in production - and this gate then attached no
|
|
33
|
+
// middleware at all to the 300-plus routes below, including the ones that
|
|
34
|
+
// read and rewrite the project's `.env`, write the deploy script, sync RBAC
|
|
35
|
+
// roles and dump arbitrary model rows. `@stacksjs/auth`'s cookie policy hit
|
|
36
|
+
// the same shape in stacksjs/stacks#2275 and moved to the URL; this follows.
|
|
37
|
+
const IS_LOCAL_ENV = isLocalDeployment()
|
|
30
38
|
|
|
31
39
|
// Apply auth + admin-role middleware to a sensitive route outside local envs.
|
|
32
40
|
// Returns the route builder so calls read as `guard(route.post(...))`.
|
package/routes/dashboard.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import
|
|
18
|
+
import { isLocalDeployment } from '@stacksjs/env'
|
|
19
19
|
import { route } from '@stacksjs/router'
|
|
20
20
|
|
|
21
21
|
// ============================================================================
|
|
@@ -86,8 +86,10 @@ route.health()
|
|
|
86
86
|
// Apps that intentionally want either route in production can
|
|
87
87
|
// re-register the path in `routes/api.ts` — user routes load first,
|
|
88
88
|
// so their copy wins.
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// Same rule as dashboard-api.ts: a deployment is local when its URL says so.
|
|
90
|
+
// The environment name alone sent these two to production in any app whose
|
|
91
|
+
// `.env` still carried the `APP_ENV=development` that `.env.example` ships.
|
|
92
|
+
const IS_LOCAL_ENV = isLocalDeployment()
|
|
91
93
|
|
|
92
94
|
if (IS_LOCAL_ENV) {
|
|
93
95
|
route.get('/install', 'Actions/InstallAction')
|