@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.
@@ -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 `max_uses`, `is_active` and the
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
- `max_uses` of `NULL` means unlimited. Do not fetch, check and then call
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. **27 of them are auto-imported**;
12
- the rest need an explicit import from `@stacksjs/composables`.
13
-
14
- `storage/framework/browser-auto-imports.json` is the authority, and it declares
15
- 83 names in total, 27 of which are `use*`. This page said "All are auto-imported
16
- in STX templates", which is the same mistake `AGENTS.md` carries a scar about
17
- under "200+ composables": an agent reaching for a name on that authority writes
18
- a template that does not run, and reads the failure as a framework bug.
19
-
20
- ## The 27 you can write bare in a template
21
-
22
- Everything else on this page needs an explicit import, and that is most of what
23
- the sections below list - `useCounter`, `useLocalStorage`, `useMouse`,
24
- `useScroll`, `useIntersectionObserver` and `usePreferredReducedMotion` among
25
- them, which are exactly the names `AGENTS.md` calls out for having been wrongly
26
- listed as free.
27
-
28
- No count of how many fall on each side, deliberately. That number is
29
- maintained by hand and moves whenever a name is added to any section on this
30
- page - it was wrong within one edit of being written. The list below is the
31
- whole of the free side; anything not on it is on the other.
32
-
33
- <!-- auto-imported:begin - checked against browser-auto-imports.json by
34
- core/server/tests/composables-skill-auto-imports.test.ts. Both directions:
35
- every name here must be in the manifest, and every `use*` in the manifest
36
- must be here. Edit the manifest, not this list. -->
37
-
38
- `useAbs`, `useAuth`, `useAverage`, `useCeil`, `useClamp`, `useDark`,
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
- A name not on that list is imported:
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 { useCounter } from '@stacksjs/composables'
46
+ import { useStorage } from '@stacksjs/composables'
50
47
  ```
51
48
 
52
- **And the 27 are free only in the STX script entry.** The injection does not
53
- reach a TypeScript module that entry imports - bindings there do not leak into
54
- bundled module scope - so a `.ts` file beside your template imports every one
55
- of these explicitly, same as any other name. `AGENTS.md` states the same limit
56
- under Auto-imports. Referencing an uninjected name is a ReferenceError thrown
57
- before any binding applies, which takes the whole page down rather than
58
- degrading the one call.
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 27 of the 154 composables are auto-imported in STX templates. Check
204
- `storage/framework/browser-auto-imports.json` before relying on a bare name;
205
- everything else needs `import { … } from '@stacksjs/composables'`
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/` (228 migration files, `.sql` format)
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 228 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
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
- - 228 migration files exist by default for all framework models
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
- // `Auth.logout()` resolves the token from the Authorization header or the
11
- // auth cookie, so this revokes the session either way.
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
- return response.json(
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 { TeamStateConflictError, teamOperationalError } from '../../Teams/team-response'
5
+ import { teamOperationalError } from '../../Teams/team-response'
6
6
  import { syncTeamMemberCount } from './team-member-count'
7
- import { changedRows, normalizeInvitationRole, parsePositiveId, sqlTimestamp } from './team-records'
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
- const member = await trx
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
- .executeTakeFirst()
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
- const updated = await trx
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
- .executeTakeFirst()
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
- // Get counts by query type
18
- const queryTypeStats = await db
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
- sql`json_extract(tags, "$[0]")`.as('type'),
61
+ 'tags',
22
62
  db.fn.count('id').as('count'),
63
+ db.fn.avg('duration').as('avg_duration'),
23
64
  ])
24
- .groupBy(sql`json_extract(tags, "$[0]")`)
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`strftime("%Y-%m-%d %H:00:00", executed_at)`.as('hour'),
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
- .whereRaw(sql`executed_at >= datetime("now", "-1 day")`)
53
- .groupBy(sql`strftime("%Y-%m-%d %H:00:00", executed_at)`)
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: queryTypeStats,
100
+ byType: [...byType].map(([type, entry]) => ({ type, count: entry.count })),
60
101
  byStatus: statusStats,
61
- avgDuration: durationStats,
62
- slowQueriesTimeline,
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 interval: string
282
- let timeConstraint: string
328
+ let bucketWidth: number
329
+ let windowMs: number
283
330
 
284
- // Set time grouping format and constraint based on timeframe
331
+ // Set the bucket width and how far back to look, per timeframe.
285
332
  switch (timeframe) {
286
333
  case 'week':
287
- interval = '%Y-%m-%d'
288
- timeConstraint = '-7 day'
334
+ bucketWidth = DAY_PREFIX
335
+ windowMs = 7 * DAY_MS
289
336
  break
290
337
  case 'month':
291
- interval = '%Y-%m-%d'
292
- timeConstraint = '-30 day'
338
+ bucketWidth = DAY_PREFIX
339
+ windowMs = 30 * DAY_MS
293
340
  break
294
341
  case 'day':
295
342
  default:
296
- interval = '%Y-%m-%d %H:00:00'
297
- timeConstraint = '-24 hour'
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
- // `interval` and `timeConstraint` are chosen from the fixed set a
305
- // few lines above, never from the request, so interpolating them
306
- // into the fragment is safe. `sql.literal` does not exist on this
307
- // tag, which is what made this a type error.
308
- sql`strftime('${interval}', executed_at)`.as('time_interval'),
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
- .whereRaw(sql`executed_at >= datetime("now", '${timeConstraint}')`)
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
- // Parameterized via db.unsafe: the delete builder can neither
401
- // bind a datetime() expression nor render raw WHERE fragments.
402
- const statement = await (db as any).unsafe(
403
- `DELETE FROM query_logs WHERE executed_at < datetime("now", ?)`,
404
- [`-${retentionDays} day`],
405
- )
406
- const result = typeof statement?.execute === 'function' ? await statement.execute() : statement
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
- pruned: Number(result?.changes ?? result?.numDeletedRows ?? 0),
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
- belongsToMany: {
45
- posts: {
46
- model: 'Post',
47
- table: 'categorizable_models',
48
- foreignKey: 'category_id',
49
- relatedKey: 'categorizable_id',
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: {
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.45",
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": "^3.7.2-12"
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.45",
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.45",
58
+ "@stacksjs/mobile": "^0.74.47",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -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
- const APP_ENV = (process.env.APP_ENV ?? process.env.NODE_ENV ?? '').toLowerCase()
29
- const IS_LOCAL_ENV = APP_ENV === '' || APP_ENV === 'local' || APP_ENV === 'development' || APP_ENV === 'dev' || APP_ENV === 'test' || APP_ENV === 'testing'
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(...))`.
@@ -15,7 +15,7 @@
15
15
  * ```
16
16
  */
17
17
 
18
- import process from 'node:process'
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
- const APP_ENV = (process.env.APP_ENV ?? process.env.NODE_ENV ?? '').toLowerCase()
90
- const IS_LOCAL_ENV = APP_ENV === '' || APP_ENV === 'local' || APP_ENV === 'development' || APP_ENV === 'dev' || APP_ENV === 'test' || APP_ENV === 'testing'
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')