镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit 5bc42e5

Browse files
committed
feat: bound retention cleanup and pin highlighted dashboard models
1 parent 1c7bb68 commit 5bc42e5

12 files changed

Lines changed: 195 additions & 39 deletions

File tree

‎storage/framework/core/orm/src/define-model.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1328,10 +1328,11 @@ function addStaticHelpers(baseModel: Record<string, unknown>, definition: BQBMod
13281328
const instance = await (make as Function).call(baseModel) as {
13291329
forceFill?: (data: Record<string, unknown>) => unknown
13301330
save?: () => Promise<unknown>
1331+
saveAsync?: () => Promise<unknown>
13311332
} | null
13321333
if (instance && typeof instance.forceFill === 'function' && typeof instance.save === 'function') {
13331334
instance.forceFill(data)
1334-
const saved = await instance.save()
1335+
const saved = await (typeof instance.saveAsync === 'function' ? instance.saveAsync() : instance.save())
13351336
// The wrapped instance, never the builder's raw `this`: that one
13361337
// reads every column as undefined (see keepWrapped).
13371338
return saved && typeof saved === 'object' && saved !== instance && !(saved as { [STACKS_PROXY_TAG]?: boolean })[STACKS_PROXY_TAG]

‎storage/framework/core/orm/src/gdpr/engine.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export interface GdprPruneOptions extends GdprRunOptions {
5757
dryRun?: boolean
5858
/** The moment ages are measured from. @default new Date() */
5959
now?: Date
60+
/** Maximum rows scanned per model in this call. Continue with the returned `next` cursors. */
61+
batchSize?: number
62+
/** Primary-key cursors from a previous bounded call, with the same `now`. */
63+
after?: Record<string, string | number>
6064
}
6165

6266
export class GdprSubjectNotFoundError extends Error {
@@ -501,10 +505,14 @@ const DAY_MS = 86_400_000
501505
export async function pruneRetainedData(options: GdprPruneOptions = {}): Promise<GdprPruneResult> {
502506
const dryRun = options.dryRun === true
503507
const now = options.now ?? new Date()
508+
if (!Number.isFinite(now.getTime())) throw new RangeError('Retention requires a valid date')
509+
if (options.batchSize !== undefined && (!Number.isSafeInteger(options.batchSize) || options.batchSize < 1 || options.batchSize > 10000))
510+
throw new RangeError('Retention batchSize must be an integer from 1 to 10000')
511+
const next: Record<string, string | number> = {}
504512
const plan = await loadPlan(options.models)
505513
const policies = plan.models.filter(model => model.retention)
506514
if (!policies.length)
507-
return { dryRun, cutoffs: {}, changes: [], skipped: [] }
515+
return { dryRun, cutoffs: {}, changes: [], skipped: [], ...(options.batchSize === undefined ? {} : { next }) }
508516

509517
if (!dryRun)
510518
await requireAuditTable()
@@ -520,13 +528,14 @@ export async function pruneRetainedData(options: GdprPruneOptions = {}): Promise
520528
cutoffs[model.model] = cutoff.toISOString()
521529

522530
const expired: Row[] = []
523-
let last: unknown
531+
let last: unknown = options.after?.[model.model]
524532
for (;;) {
525533
const handle: Handle = db
526534
let query = handle.selectFrom(model.table).selectAll().where(retention.column, '<', bound)
527535
if (last !== undefined)
528536
query = query.where(model.primaryKey, '>', last)
529-
const page: Row[] = await query.orderBy(model.primaryKey, 'asc').limit(CHUNK).execute()
537+
const size = options.batchSize ?? CHUNK
538+
const page: Row[] = await query.orderBy(model.primaryKey, 'asc').limit(size).execute()
530539
if (!page.length)
531540
break
532541
for (const row of page) {
@@ -535,6 +544,14 @@ export async function pruneRetainedData(options: GdprPruneOptions = {}): Promise
535544
expired.push(row)
536545
}
537546
last = page[page.length - 1]![model.primaryKey]
547+
if (options.batchSize !== undefined) {
548+
if (page.length === size) {
549+
if (typeof last === 'bigint') last = last.toString()
550+
if (typeof last !== 'string' && typeof last !== 'number') throw new TypeError('Retention requires a string or numeric primary key')
551+
next[model.model] = last
552+
}
553+
break
554+
}
538555
if (page.length < CHUNK)
539556
break
540557
}
@@ -546,6 +563,10 @@ export async function pruneRetainedData(options: GdprPruneOptions = {}): Promise
546563
await deleteRows(handle, model, pending)
547564
else
548565
await anonymizeRows(handle, model, pending)
566+
if (options.batchSize !== undefined) await recordAudit(handle, 'retention', null, options.actor ?? 'scheduler', {
567+
changes: [{ model: model.model, action: retention.action, matched: expired.length, changed: pending.length }],
568+
cutoffs: { [model.model]: cutoff.toISOString() },
569+
})
549570
})
550571
}
551572

@@ -559,12 +580,12 @@ export async function pruneRetainedData(options: GdprPruneOptions = {}): Promise
559580
})
560581
}
561582

562-
if (!dryRun && changes.some(change => change.changed > 0)) {
583+
if (!dryRun && options.batchSize === undefined && changes.some(change => change.changed > 0)) {
563584
await recordAudit(db, 'retention', null, options.actor ?? 'scheduler', {
564585
changes: changes.map(({ model, action, matched, changed }) => ({ model, action, matched, changed })),
565586
cutoffs,
566587
})
567588
}
568589

569-
return { dryRun, cutoffs, changes: changes.sort((a, b) => a.model.localeCompare(b.model)), skipped }
590+
return { dryRun, cutoffs, changes: changes.sort((a, b) => a.model.localeCompare(b.model)), skipped, ...(options.batchSize === undefined ? {} : { next }) }
570591
}

‎storage/framework/core/orm/src/gdpr/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export interface GdprErasureResult {
154154
}
155155

156156
export interface GdprPruneResult {
157+
/** Cursors for models with another bounded page to scan. Reuse the same cutoff date. */
158+
next?: Record<string, string | number>
157159
dryRun: boolean
158160
cutoffs: Record<string, string>
159161
changes: GdprModelChange[]

‎storage/framework/core/orm/tests/force-create-guarded.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ describe('forceCreate persists guarded columns', () => {
6464
expect(row).toEqual({ name: 'yelp', api_key: 'secret-key', note: 'set deliberately' })
6565
})
6666

67+
it('awaits asynchronous setters exactly once through the trusted create path', async () => {
68+
let calls = 0
69+
const AsyncCredential = defineModel({
70+
name: 'AsyncFcCredential', table: 'fc_credentials',
71+
attributes: { name: { fillable: true }, apiKey: { guarded: true }, note: { guarded: true } },
72+
set: { note: async (attributes: Record<string, unknown>) => { calls++; return `encoded:${attributes.note}` } },
73+
} as const)
74+
const created = await AsyncCredential.forceCreate({ name: 'async', apiKey: 'key', note: 'plain' })
75+
const row = db.query('SELECT note FROM fc_credentials WHERE id = ?').get(Number(created.id)) as { note: string }
76+
expect(row.note).toBe('encoded:plain')
77+
expect(calls).toBe(1)
78+
})
79+
6780
it('returns a row carrying the guarded values', async () => {
6881
const created = await (Credential as any).forceCreate({ name: 'google', apiKey: 'another-key' })
6982
expect(Number(created.id)).toBeGreaterThan(0)

‎storage/framework/core/orm/tests/gdpr.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,48 @@ describe('retention', () => {
452452
expect(result.skipped).toEqual(['ghosts'])
453453
expect(result.changes.find(change => change.model === 'Visit')!.changed).toBe(2)
454454
})
455+
456+
test('bounded retention advances over unexpired candidates and resumes the same cutoff', async () => {
457+
const scoped = { Visit: models.Visit }
458+
let after: Record<string, string | number> | undefined
459+
let calls = 0
460+
let deleted = 0
461+
do {
462+
const result = await pruneRetainedData({ models: scoped, now, batchSize: 1, after })
463+
expect(result.changes[0]!.changed).toBeLessThanOrEqual(1)
464+
expect(result.cutoffs.Visit).toBe('2026-05-02T12:00:00.000Z')
465+
deleted += result.changes[0]!.changed
466+
after = result.next
467+
calls++
468+
expect(calls).toBeLessThan(6)
469+
} while (Object.keys(after ?? {}).length)
470+
expect(deleted).toBe(2)
471+
expect((await rows('visits')).map(row => row.path)).toEqual(['/edge-inside', '/new', '/undated'])
472+
expect(calls).toBe(4)
473+
expect((await pruneRetainedData({ models: scoped, now, batchSize: 1 })).changes[0]!.changed).toBe(0)
474+
})
475+
476+
test('bounded previews preserve data and invalid bounds fail before writes', async () => {
477+
const before = await rows('visits')
478+
const scoped = { Visit: models.Visit }
479+
const result = await pruneRetainedData({ models: scoped, now, batchSize: 1, dryRun: true })
480+
expect(result.next?.Visit).toBeDefined()
481+
expect(await rows('visits')).toEqual(before)
482+
for (const batchSize of [0, -1, 1.5, 10001, Number.NaN])
483+
await expect(pruneRetainedData({ models: scoped, now, batchSize })).rejects.toThrow('batchSize')
484+
await expect(pruneRetainedData({ models: scoped, now: new Date('invalid'), batchSize: 1 })).rejects.toThrow('valid date')
485+
expect(await rows('gdpr_requests')).toHaveLength(0)
486+
})
487+
488+
test('bounded deletion rolls back when its audit cannot be saved', async () => {
489+
const before = await rows('visits')
490+
await db.unsafe("CREATE TRIGGER reject_retention_audit BEFORE INSERT ON gdpr_requests BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END").execute()
491+
try {
492+
await expect(pruneRetainedData({ models: { Visit: models.Visit }, now, batchSize: 1 })).rejects.toThrow('audit unavailable')
493+
expect(await rows('visits')).toEqual(before)
494+
}
495+
finally { await db.unsafe('DROP TRIGGER reject_retention_audit').execute() }
496+
})
455497
})
456498

457499
describe('processing register', () => {

‎storage/framework/core/types/src/dashboard.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ export interface DashboardModelOptions {
396396
label?: string
397397

398398
/**
399-
* Give this model a prominent row. A number orders the highlighted set;
399+
* Pin this model in Data above the collapsed More models group. A number orders the highlighted set;
400400
* `true` highlights without taking a position.
401401
*/
402402
highlight?: boolean | number

‎storage/framework/defaults/ai/skills/stacks-dashboard/SKILL.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,3 +740,11 @@ by itself: configure `createDatabaseErrorReporter()` from
740740
`Error`, and migrate it. Reporting is bounded, buffered, identity-deduplicated
741741
by `captureError`, and flushed by `flushErrorReporters`/the logger. The database
742742
reporter stores allowlisted operational context and excludes bodies and headers.
743+
744+
### Pinned model rows
745+
746+
Declare `dashboard: { highlight: true }` on a model to pin its Data row, or use
747+
numeric `highlight` values to order pinned rows. Other enabled Data models live
748+
under the native Sidebar's collapsed More models disclosure. Labels, icons and
749+
role gates apply to nested rows too. `enabled: false` omits navigation;
750+
`access: false` excludes generic catalogs and reads as well.

‎storage/framework/defaults/ai/skills/stacks-models/SKILL.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,14 @@ commands, not a project's resolved models.
519519
of a validator is not a promise that an arbitrary request body is safe.
520520
- Dashboard-highlighted models (`dashboard: { highlight: true }`) appear
521521
prominently in the admin UI.
522+
523+
### Bounded retention jobs
524+
525+
`pruneRetainedData({ models: { Request }, now, batchSize: 500, after })` scans at
526+
most 500 candidates per model. Persist `result.next` in the next queue job and
527+
reuse the same `now` until no cursors remain, even when a page changed zero rows.
528+
This handles mixed SQL/ISO timestamps without skipping candidates. Each bounded
529+
page commits its deletion/anonymization and retention audit together. A dry run
530+
returns cursors without changing records. Omitting `batchSize` retains the CLI's
531+
whole-run behavior. Declare the policy on `traits.gdpr.retention`, publish the
532+
native `GdprRequest` audit model, and run cleanup through durable queue workers.

‎storage/framework/defaults/resources/functions/dashboard/sidebar.ts‎

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type ModelCategory = 'userland' | 'data' | 'commerce' | 'content' | 'marketing'
1818
* full type imports aren't available. Keep in sync with the source.
1919
*/
2020
interface DashboardModelOptionsLite {
21+
highlight?: boolean | number
2122
access?: boolean
2223
enabled?: boolean
2324
label?: string
@@ -102,6 +103,9 @@ export const DEFAULT_TOGGLES: DashboardSectionToggles = {
102103
}
103104

104105
interface NavItem {
106+
id?: string
107+
children?: NavItem[]
108+
expanded?: boolean
105109
to: string
106110
icon: string
107111
text: string
@@ -413,37 +417,50 @@ export function buildNavSections(
413417
...categoryNavItems(discoveredModels, 'system', new Set()),
414418
]])
415419

416-
// Data section: configurable basic rows + ALL userland models + any
417-
// root-level framework `data` models. Per-row toggles let projects hide
418-
// the basic rows they don't need; userland models always appear.
420+
// Data pins model-declared highlights. Other visible models stay accessible
421+
// through a collapsed disclosure using the shared Sidebar component.
419422
// Per-model rows link to `/models/<id>` (dynamic ORM viewer); the
420423
// built-in dashboard / activity / users / teams / subscribers rows keep
421424
// their bespoke pages under `/data/<row>`.
422425
const dataRows = toggles.data ?? DEFAULT_DATA_TOGGLES
426+
const dedicatedDataRows: Record<string, keyof DataRowToggles> = { user: 'users', team: 'teams', subscriber: 'subscribers', activity: 'activity' }
423427
const dataNav: NavItem[] = []
424428
if (dataRows.dashboard) dataNav.push({ to: '/data/dashboard', icon: 'dashboard', text: 'Dashboard' })
425429
if (dataRows.activity) dataNav.push({ to: '/data/activity', icon: 'activity', text: 'Activity' })
426430
if (dataRows.users) dataNav.push({ to: '/data/users', icon: 'users', text: 'Users' })
427431
if (dataRows.teams) dataNav.push({ to: '/data/teams', icon: 'group', text: 'Teams' })
428432
if (dataRows.subscribers) dataNav.push({ to: '/data/subscribers', icon: 'mail', text: 'Subscribers' })
429433
if (dataRows.allModels) dataNav.push({ to: '/models', icon: 'table', text: 'All Models' })
430-
for (const m of discoveredModels) {
434+
const moreModels: NavItem[] = []
435+
const highlighted = (model: DiscoveredModel) => model.dashboard?.highlight === true
436+
|| (typeof model.dashboard?.highlight === 'number' && Number.isFinite(model.dashboard.highlight))
437+
const rank = (model: DiscoveredModel) => typeof model.dashboard?.highlight === 'number' ? model.dashboard.highlight : Number.POSITIVE_INFINITY
438+
const dataModels = [...discoveredModels].sort((a, b) => {
439+
if (highlighted(a) !== highlighted(b)) return highlighted(a) ? -1 : 1
440+
if (highlighted(a)) return rank(a) - rank(b) || a.name.localeCompare(b.name)
441+
return a.name.localeCompare(b.name)
442+
})
443+
for (const m of dataModels) {
431444
// Skip when:
432445
// - explicit `dashboard.enabled === false` (effectiveCategory returns undefined)
433446
// - pinned to a different section via `dashboard.section`
434447
// - already has a dedicated dashboard page
435448
const eff = effectiveCategory(m)
436449
if (eff !== 'userland' && eff !== 'data') continue
437-
if (m.hasDedicatedPage) continue
438-
dataNav.push({
450+
const dedicatedRow = dedicatedDataRows[m.id]
451+
if (m.hasDedicatedPage && (!dedicatedRow || dataRows[dedicatedRow])) continue
452+
const item: NavItem = {
439453
to: `/models/${m.id}`,
440454
icon: m.dashboard?.icon ?? m.icon ?? 'table',
441455
text: m.dashboard?.label ?? m.name,
442456
...(m.dashboard?.roles && m.dashboard.roles.length > 0
443457
? { roles: m.dashboard.roles }
444458
: {}),
445-
})
459+
}
460+
if (highlighted(m)) dataNav.push(item)
461+
else moreModels.push(item)
446462
}
463+
if (moreModels.length) dataNav.push({ id: 'models-more', to: '', text: 'More models', icon: 'table', expanded: false, children: moreModels })
447464
sections.push(['data', 'data', dataNav])
448465

449466
if (toggles.commerce) {
@@ -650,7 +667,9 @@ export interface WebSidebarItem {
650667
icon: string
651668
/** macOS system color name or CSS color for the icon tint. */
652669
iconColor: string
653-
href: string
670+
href?: string
671+
children?: WebSidebarItem[]
672+
expanded?: boolean
654673
roles?: string[]
655674
}
656675

@@ -726,6 +745,15 @@ export function buildWebSidebarSections(): WebSidebarSection[] {
726745
const appNav = manifest.nav.length > 0 ? manifest.nav : loadAppNavFromConfig()
727746
const sections = filterSchemaNavSections(buildNavSections(manifest.models, manifest.sections, appNav), manifest.schemaModels)
728747

748+
const webItem = (item: NavItem): WebSidebarItem => ({
749+
id: item.id ?? navItemId(item.to),
750+
label: item.text,
751+
icon: resolveNavIcon(item.icon),
752+
iconColor: 'blue',
753+
...(item.to ? { href: item.to } : {}),
754+
...(item.roles?.length ? { roles: item.roles } : {}),
755+
...(item.children ? { children: item.children.map(webItem), expanded: item.expanded ?? false } : {}),
756+
})
729757
return [
730758
{
731759
id: 'top',
@@ -735,14 +763,20 @@ export function buildWebSidebarSections(): WebSidebarSection[] {
735763
...sections.map(([key, label, items]) => ({
736764
id: key,
737765
label: titleCase(label),
738-
items: items.map(item => ({
739-
id: navItemId(item.to),
740-
label: item.text,
741-
icon: resolveNavIcon(item.icon),
742-
iconColor: 'blue',
743-
href: item.to,
744-
...(item.roles && item.roles.length > 0 ? { roles: item.roles } : {}),
745-
})),
766+
items: items.map(webItem),
746767
})),
747768
]
748769
}
770+
771+
/** Role metadata follows nested model rows as well as pinned rows. */
772+
export function webSidebarRoleMap(sections: WebSidebarSection[]): Record<string, string[]> {
773+
const roles: Record<string, string[]> = Object.create(null)
774+
const visit = (items: WebSidebarItem[]) => {
775+
for (const item of items) {
776+
if (item.roles?.length) roles[item.id] = item.roles
777+
if (item.children) visit(item.children)
778+
}
779+
}
780+
for (const section of sections) visit(section.items)
781+
return roles
782+
}

‎storage/framework/defaults/views/dashboard/layouts/default.stx‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
// vendored or linked. `@stacksjs/defaults/*` is mapped onto the vendored tree in
1717
// `storage/framework/tsconfig.app.json` and falls back to the installed
1818
// package when there is none, so it resolves in both layouts.
19-
import { buildWebSidebarSections } from '@stacksjs/defaults/resources/functions/dashboard/sidebar'
19+
import { buildWebSidebarSections, webSidebarRoleMap } from '@stacksjs/defaults/resources/functions/dashboard/sidebar'
2020

2121
export const sidebarSections = buildWebSidebarSections()
2222

@@ -31,15 +31,7 @@ export const sidebarSections = buildWebSidebarSections()
3131
// then ran `Object.entries` over that string, which yields index/character
3232
// pairs, and called `.some()` on a single character. Every dashboard load
3333
// threw inside applyRoleFilter before it could filter anything.
34-
export const sidebarRoleMap: Record<string, string[]> = Object.fromEntries(
35-
sidebarSections.flatMap(section =>
36-
section.items.flatMap(item =>
37-
item.roles && item.roles.length > 0
38-
? [[item.id, item.roles] as const]
39-
: [],
40-
),
41-
),
42-
)
34+
export const sidebarRoleMap: Record<string, string[]> = webSidebarRoleMap(sidebarSections)
4335

4436
// Declared, so the client block below gets the map typed rather than inferred
4537
// off the implicit bridge - which is what let the mismatch above go unseen.

0 commit comments

Comments
 (0)