@stacksjs/defaults 0.70.366 → 0.70.368

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +49 -1
  3. package/ai/skills/stacks-deploy/SKILL.md +18 -0
  4. package/ai/skills/stacks-desktop/SKILL.md +11 -2
  5. package/ai/skills/stacks-stx/SKILL.md +25 -0
  6. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
  7. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
  8. package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
  9. package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
  10. package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
  11. package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
  12. package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
  13. package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
  14. package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
  15. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
  16. package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
  17. package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
  18. package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
  19. package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
  20. package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
  21. package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
  22. package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
  23. package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
  24. package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
  25. package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
  26. package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
  27. package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
  28. package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
  29. package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
  30. package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
  31. package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
  32. package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
  33. package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
  34. package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
  35. package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
  36. package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
  37. package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
  38. package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
  39. package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
  40. package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
  41. package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
  42. package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
  43. package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
  44. package/functions/deployments.ts +18 -0
  45. package/functions/operations.ts +361 -0
  46. package/ide/vscode/package.json +1 -1
  47. package/package.json +1 -1
  48. package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
  49. package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
  50. package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
  51. package/resources/components/Dashboard/GlobalSearch.stx +87 -91
  52. package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
  53. package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
  54. package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
  55. package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
  56. package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
  57. package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
  58. package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
  59. package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
  60. package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
  61. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
  62. package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
  63. package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
  64. package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
  65. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
  66. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
  67. package/resources/components/Dashboard/UI/Modal.stx +33 -8
  68. package/resources/functions/dashboard/sidebar.ts +1 -0
  69. package/routes/dashboard-api.ts +23 -0
  70. package/routes/dashboard.ts +1 -0
  71. package/vcs/github/workflows/ci.yml +3 -3
  72. package/vcs/github/workflows/release.yml +1 -1
  73. package/views/dashboard/operations/audit.stx +5 -0
  74. package/views/dashboard/operations/changes.stx +5 -0
  75. package/views/dashboard/operations/incidents.stx +5 -0
  76. package/views/dashboard/operations/migrations.stx +8 -0
  77. package/views/dashboard/operations/recovery.stx +8 -0
  78. package/views/dashboard/operations/scheduler.stx +8 -0
  79. package/views/dashboard/stores/ci.ts +2 -2
@@ -0,0 +1,72 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { appendOperatorEvent, dashboardOperator } from './control-plane'
5
+ import { jsonObject, stringValue } from './recovery-input'
6
+ import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
7
+
8
+ export default new Action({
9
+ name: 'RecoveryRestoreAction',
10
+ description: 'Validates or queues an isolated, in-place, or drill restore.',
11
+ method: 'POST',
12
+ apiResponse: true,
13
+ async handle(request: RequestInstance) {
14
+ const input = request.all() as Record<string, unknown>
15
+ const runtime = recoveryRuntime()
16
+ const { controlPlane } = recoveryEnvironment()
17
+ const point = runtime.store.getRecoveryPoint(String(request.getParam('id') || ''))
18
+ if (!point || point.projectId !== controlPlane.project.id)
19
+ return response.json({ message: 'Recovery point not found.' }, 404)
20
+
21
+ const mode = stringValue(input.mode) || 'isolated'
22
+ const targetName = stringValue(input.targetName)
23
+ const drill = input.drill === true
24
+ const execute = input.execute === true
25
+ if (!['isolated', 'in_place'].includes(mode))
26
+ return response.json({ message: 'Restore mode must be isolated or in_place.' }, 422)
27
+ if (!targetName)
28
+ return response.json({ message: 'A restore target name is required.' }, 422)
29
+ if (drill && mode === 'in_place')
30
+ return response.json({ message: 'Recovery drills require an isolated target.' }, 422)
31
+
32
+ const safetyBackupId = stringValue(input.safetyBackupId) || undefined
33
+ if (safetyBackupId) {
34
+ const safety = runtime.store.getRecoveryPoint(safetyBackupId)
35
+ if (!safety || safety.projectId !== controlPlane.project.id)
36
+ return response.json({ message: 'The safety recovery point was not found.' }, 422)
37
+ }
38
+ const target = point.kind === 'volume'
39
+ ? { ...jsonObject(input.target), volumeName: targetName, inPlace: mode === 'in_place' }
40
+ : point.kind === 'logical_database'
41
+ ? { ...jsonObject(input.target), targetId: targetName, dataServiceId: targetName, inPlace: mode === 'in_place' }
42
+ : { ...jsonObject(input.target), targetId: targetName, inPlace: mode === 'in_place' }
43
+ const restoreInput = {
44
+ mode: mode as 'isolated' | 'in_place',
45
+ target,
46
+ targetName,
47
+ confirm: stringValue(input.confirm) || undefined,
48
+ recentAuth: true,
49
+ downtimeAcknowledged: input.downtimeAcknowledged === true,
50
+ safetyBackupId,
51
+ }
52
+
53
+ try {
54
+ const plan = runtime.coordinator.planRestore(point, restoreInput)
55
+ if (!execute)
56
+ return { success: true, plan, productionExecutionCreated: false }
57
+
58
+ const actor = await dashboardOperator(request)
59
+ const job = runtime.coordinator.enqueueRestore(point, { ...restoreInput, drill, actorId: actor.id })
60
+ await appendOperatorEvent(request, drill ? 'backup.drill_queued' : 'backup.restore_queued', {
61
+ recoveryPointId: point.id,
62
+ backupJobId: job.id,
63
+ mode: restoreInput.mode,
64
+ target: targetName,
65
+ }, point.resourceId)
66
+ return response.json({ success: true, plan, job, productionExecutionCreated: true }, 202)
67
+ }
68
+ catch (error) {
69
+ return response.json({ message: error instanceof Error ? error.message : String(error) }, 409)
70
+ }
71
+ },
72
+ })
@@ -0,0 +1,17 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { appendOperatorEvent } from './control-plane'
5
+ import { recoveryRuntime } from './recovery-runtime'
6
+
7
+ export default new Action({
8
+ name: 'RecoveryRetentionAction',
9
+ description: 'Queues cleanup of expired, unlocked, unheld recovery points.',
10
+ method: 'POST',
11
+ apiResponse: true,
12
+ async handle(request: RequestInstance) {
13
+ const jobs = recoveryRuntime().coordinator.enqueueRetention()
14
+ await appendOperatorEvent(request, 'backup.retention_queued', { jobs: jobs.length })
15
+ return response.json({ success: true, jobs, message: `Queued ${jobs.length} retention cleanup job${jobs.length === 1 ? '' : 's'}.` }, 202)
16
+ },
17
+ })
@@ -0,0 +1,25 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { appendOperatorEvent, dashboardOperator } from './control-plane'
5
+ import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
6
+
7
+ export default new Action({
8
+ name: 'RecoveryRunAction',
9
+ description: 'Queues an on-demand run for one configured recovery policy.',
10
+ method: 'POST',
11
+ apiResponse: true,
12
+ async handle(request: RequestInstance) {
13
+ const runtime = recoveryRuntime()
14
+ const { controlPlane, environment } = recoveryEnvironment()
15
+ const policy = runtime.store.getPolicy(String(request.getParam('id') || ''))
16
+ if (!policy || policy.projectId !== controlPlane.project.id || policy.environmentId !== environment.id)
17
+ return response.json({ message: 'Recovery policy not found.' }, 404)
18
+ if (!policy.enabled)
19
+ return response.json({ message: 'Enable this recovery policy before running it.' }, 409)
20
+ const actor = await dashboardOperator(request)
21
+ const job = runtime.coordinator.enqueueBackup(policy, new Date().toISOString(), actor.id)
22
+ await appendOperatorEvent(request, 'backup.run_queued', { policyId: policy.id, backupJobId: job.id }, policy.resourceId)
23
+ return response.json({ success: true, job, message: `Backup job ${job.id} was queued.` }, 202)
24
+ },
25
+ })
@@ -0,0 +1,24 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { appendOperatorEvent } from './control-plane'
5
+ import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
6
+
7
+ export default new Action({
8
+ name: 'RecoveryVerifyAction',
9
+ description: 'Queues independent verification for one available recovery point.',
10
+ method: 'POST',
11
+ apiResponse: true,
12
+ async handle(request: RequestInstance) {
13
+ const runtime = recoveryRuntime()
14
+ const { controlPlane } = recoveryEnvironment()
15
+ const point = runtime.store.getRecoveryPoint(String(request.getParam('id') || ''))
16
+ if (!point || point.projectId !== controlPlane.project.id)
17
+ return response.json({ message: 'Recovery point not found.' }, 404)
18
+ if (point.status !== 'available')
19
+ return response.json({ message: 'Only available recovery points can be verified.' }, 409)
20
+ const job = runtime.coordinator.enqueueVerification(point)
21
+ await appendOperatorEvent(request, 'backup.verification_queued', { recoveryPointId: point.id, backupJobId: job.id }, point.resourceId)
22
+ return response.json({ success: true, job, message: `Verification job ${job.id} was queued.` }, 202)
23
+ },
24
+ })
@@ -0,0 +1,34 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { dashboardOperationalError } from '../dashboard-response'
4
+ import { recentOperatorOperations } from './control-plane'
5
+ import { listSchedulerTasks } from './scheduler-operations'
6
+
7
+ export default new Action({
8
+ name: 'SchedulerIndexAction',
9
+ description: 'Lists registered scheduled tasks and their durable operator history.',
10
+ method: 'GET',
11
+ apiResponse: true,
12
+ async handle(_request: RequestInstance) {
13
+ try {
14
+ const tasks = await listSchedulerTasks()
15
+ const operations = recentOperatorOperations('dashboard.scheduler.', 20)
16
+ return {
17
+ tasks,
18
+ operations,
19
+ summary: {
20
+ total: tasks.length,
21
+ active: tasks.filter(task => task.enabled).length,
22
+ paused: tasks.filter(task => !task.enabled).length,
23
+ nextRun: tasks
24
+ .map(task => task.nextRun)
25
+ .filter((value): value is string => Boolean(value))
26
+ .sort()[0] || null,
27
+ },
28
+ }
29
+ }
30
+ catch (error) {
31
+ return dashboardOperationalError(error, 'Scheduler operations could not be loaded.', 'SchedulerIndexAction')
32
+ }
33
+ },
34
+ })
@@ -0,0 +1,33 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
5
+ import { trackOperatorOperation } from './control-plane'
6
+ import { runScheduledTask, SchedulerOperationError } from './scheduler-operations'
7
+
8
+ export default new Action({
9
+ name: 'SchedulerRunAction',
10
+ description: 'Runs one registered scheduled task immediately and records the operation.',
11
+ method: 'POST',
12
+ apiResponse: true,
13
+ async handle(request: RequestInstance) {
14
+ const name = String(request.getParam('name') || '').trim()
15
+ if (!name)
16
+ return response.json({ message: 'A scheduled task name is required.' }, 422)
17
+
18
+ try {
19
+ const tracked = await trackOperatorOperation(
20
+ request,
21
+ 'dashboard.scheduler.run',
22
+ { task: name },
23
+ () => runScheduledTask(name),
24
+ )
25
+ return { success: true, ...tracked.result, operation: tracked.operation }
26
+ }
27
+ catch (error) {
28
+ if (error instanceof SchedulerOperationError)
29
+ return response.json({ message: error.message }, error.message.includes('not found') ? 404 : 409)
30
+ return dashboardOperationalError(error, 'The scheduled task could not be run.', 'SchedulerRunAction', 500)
31
+ }
32
+ },
33
+ })
@@ -0,0 +1,36 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { response } from '@stacksjs/router'
4
+ import { dashboardOperationalError } from '../dashboard-response'
5
+ import { trackOperatorOperation } from './control-plane'
6
+ import { SchedulerOperationError, setScheduledTaskEnabled } from './scheduler-operations'
7
+
8
+ export default new Action({
9
+ name: 'SchedulerToggleAction',
10
+ description: 'Persists the active state of one scheduled task and records the operation.',
11
+ method: 'PATCH',
12
+ apiResponse: true,
13
+ async handle(request: RequestInstance) {
14
+ const name = String(request.getParam('name') || '').trim()
15
+ const enabled = request.get<unknown>('enabled')
16
+ if (!name)
17
+ return response.json({ message: 'A scheduled task name is required.' }, 422)
18
+ if (typeof enabled !== 'boolean')
19
+ return response.json({ message: 'The enabled field must be a boolean.' }, 422)
20
+
21
+ try {
22
+ const tracked = await trackOperatorOperation(
23
+ request,
24
+ `dashboard.scheduler.${enabled ? 'enable' : 'disable'}`,
25
+ { enabled, task: name },
26
+ () => setScheduledTaskEnabled(name, enabled),
27
+ )
28
+ return { success: true, ...tracked.result, operation: tracked.operation }
29
+ }
30
+ catch (error) {
31
+ if (error instanceof SchedulerOperationError)
32
+ return response.json({ message: error.message }, error.message.includes('not found') ? 404 : 409)
33
+ return dashboardOperationalError(error, 'The scheduled task could not be updated.', 'SchedulerToggleAction', 500)
34
+ }
35
+ },
36
+ })
@@ -0,0 +1,150 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import type {
3
+ ControlPlaneActor,
4
+ ControlPlaneOperation,
5
+ DashboardControlPlane,
6
+ JsonValue,
7
+ } from '@stacksjs/ts-cloud'
8
+ import process from 'node:process'
9
+ import { setStateDir } from '@stacksjs/ts-cloud'
10
+ import { initializeDashboardControlPlane } from '@stacksjs/ts-cloud/deploy'
11
+ import { tsCloud } from '~/config/cloud'
12
+
13
+ let dashboardControlPlane: DashboardControlPlane | undefined
14
+
15
+ export function operationsControlPlane(): DashboardControlPlane {
16
+ if (dashboardControlPlane)
17
+ return dashboardControlPlane
18
+
19
+ setStateDir(tsCloud.stateDir)
20
+ dashboardControlPlane = initializeDashboardControlPlane(process.cwd(), tsCloud)
21
+ return dashboardControlPlane
22
+ }
23
+
24
+ export async function dashboardOperator(request: RequestInstance): Promise<ControlPlaneActor> {
25
+ const controlPlane = operationsControlPlane()
26
+ const user = await request.user()
27
+ const id = Number(user?.id)
28
+ const externalId = Number.isSafeInteger(id) && id > 0 ? `stacks-user:${id}` : 'stacks-dashboard:local'
29
+ const kind = Number.isSafeInteger(id) && id > 0 ? 'user' : 'system'
30
+ const displayName = String(user?.name || user?.email || (kind === 'system' ? 'Local dashboard' : `User ${id}`))
31
+
32
+ return controlPlane.store.getActorByExternalId(kind, externalId)
33
+ ?? controlPlane.store.createActor({
34
+ kind,
35
+ externalId,
36
+ displayName,
37
+ metadata: {
38
+ source: 'stacks-dashboard',
39
+ ...(user?.email ? { email: String(user.email) } : {}),
40
+ },
41
+ })
42
+ }
43
+
44
+ export function operationsEnvironment(controlPlane = operationsControlPlane()) {
45
+ const requested = String(process.env.CLOUD_ENV || process.env.APP_ENV || process.env.NODE_ENV || 'development').toLowerCase()
46
+ return controlPlane.environments.get(requested)
47
+ ?? controlPlane.environments.get('development')
48
+ ?? controlPlane.environments.values().next().value
49
+ }
50
+
51
+ export async function trackOperatorOperation<T extends JsonValue>(
52
+ request: RequestInstance,
53
+ kind: string,
54
+ input: JsonValue,
55
+ execute: () => Promise<T>,
56
+ ): Promise<{ result: T, operation: ControlPlaneOperation }> {
57
+ const controlPlane = operationsControlPlane()
58
+ const actor = await dashboardOperator(request)
59
+ let operation = controlPlane.store.createOperation({
60
+ projectId: controlPlane.project.id,
61
+ environmentId: operationsEnvironment(controlPlane)?.id,
62
+ actorId: actor.id,
63
+ kind,
64
+ input,
65
+ })
66
+
67
+ operation = controlPlane.store.transitionOperation(operation.id, {
68
+ to: 'running',
69
+ expectedVersion: operation.version,
70
+ })
71
+ controlPlane.store.appendEvent({
72
+ organizationId: controlPlane.organization.id,
73
+ projectId: controlPlane.project.id,
74
+ operationId: operation.id,
75
+ actorId: actor.id,
76
+ correlationId: operation.correlationId,
77
+ type: `${kind}.started`,
78
+ payload: input,
79
+ })
80
+
81
+ try {
82
+ const result = await execute()
83
+ operation = controlPlane.store.transitionOperation(operation.id, {
84
+ to: 'succeeded',
85
+ expectedVersion: operation.version,
86
+ output: result,
87
+ })
88
+ controlPlane.store.appendEvent({
89
+ organizationId: controlPlane.organization.id,
90
+ projectId: controlPlane.project.id,
91
+ operationId: operation.id,
92
+ actorId: actor.id,
93
+ correlationId: operation.correlationId,
94
+ type: `${kind}.succeeded`,
95
+ payload: result,
96
+ })
97
+ return { result, operation }
98
+ }
99
+ catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error)
101
+ operation = controlPlane.store.transitionOperation(operation.id, {
102
+ to: 'failed',
103
+ expectedVersion: operation.version,
104
+ error: message,
105
+ })
106
+ controlPlane.store.appendEvent({
107
+ organizationId: controlPlane.organization.id,
108
+ projectId: controlPlane.project.id,
109
+ operationId: operation.id,
110
+ actorId: actor.id,
111
+ correlationId: operation.correlationId,
112
+ type: `${kind}.failed`,
113
+ level: 'error',
114
+ payload: { message },
115
+ })
116
+ throw error
117
+ }
118
+ }
119
+
120
+ export async function appendOperatorEvent(
121
+ request: RequestInstance,
122
+ type: string,
123
+ payload: JsonValue,
124
+ resourceId?: string,
125
+ ): Promise<void> {
126
+ const controlPlane = operationsControlPlane()
127
+ const actor = await dashboardOperator(request)
128
+ controlPlane.store.appendEvent({
129
+ organizationId: controlPlane.organization.id,
130
+ projectId: controlPlane.project.id,
131
+ resourceId,
132
+ actorId: actor.id,
133
+ type,
134
+ payload,
135
+ })
136
+ }
137
+
138
+ export function recentOperatorOperations(prefix: string, limit = 20): Array<ControlPlaneOperation & { actorName: string }> {
139
+ const controlPlane = operationsControlPlane()
140
+ return controlPlane.store
141
+ .listOperations({ projectId: controlPlane.project.id, limit: Math.max(limit * 5, 100) })
142
+ .filter(operation => operation.kind.startsWith(prefix))
143
+ .slice(0, limit)
144
+ .map(operation => ({
145
+ ...operation,
146
+ actorName: operation.actorId
147
+ ? controlPlane.store.getActor(operation.actorId)?.displayName || 'Unknown operator'
148
+ : 'System',
149
+ }))
150
+ }
@@ -0,0 +1,80 @@
1
+ import process from 'node:process'
2
+ import { auditMigrationLedger, previewPendingMigrations, reconcileMigrationLedger } from '@stacksjs/database'
3
+
4
+ export interface MigrationPlanOperation {
5
+ kind: string
6
+ table: string
7
+ column?: string
8
+ sql?: string
9
+ destructive: boolean
10
+ }
11
+
12
+ export class MigrationOperationError extends Error {
13
+ constructor(message: string) {
14
+ super(message)
15
+ this.name = 'MigrationOperationError'
16
+ }
17
+ }
18
+
19
+ const destructiveKinds = new Set(['drop_table', 'drop_column', 'alter_column', 'truncate_table'])
20
+
21
+ export async function migrationPlan() {
22
+ const [rawOperations, ledger] = await Promise.all([
23
+ previewPendingMigrations(),
24
+ auditMigrationLedger(),
25
+ ])
26
+ const operations: MigrationPlanOperation[] = rawOperations.map((operation) => ({
27
+ kind: String(operation.kind),
28
+ table: String(operation.table),
29
+ ...('column' in operation && operation.column ? { column: String(operation.column) } : {}),
30
+ ...('sql' in operation && operation.sql ? { sql: String(operation.sql) } : {}),
31
+ destructive: destructiveKinds.has(String(operation.kind)),
32
+ }))
33
+ const revision = new Bun.CryptoHasher('sha256')
34
+ .update(JSON.stringify({ operations, ledger: ledger.entries.map(entry => [entry.file, entry.status]) }))
35
+ .digest('hex')
36
+
37
+ return {
38
+ environment: String(process.env.APP_ENV || process.env.NODE_ENV || 'development'),
39
+ dialect: ledger.dialect,
40
+ operations,
41
+ revision,
42
+ applied: ledger.recordedCount,
43
+ ledger,
44
+ summary: {
45
+ pending: operations.length,
46
+ destructive: operations.filter(operation => operation.destructive).length,
47
+ drift: ledger.drift,
48
+ ledgerIssues: ledger.entries.filter(entry => !['applied', 'pending'].includes(entry.status)).length + ledger.orphans.length,
49
+ },
50
+ }
51
+ }
52
+
53
+ export async function applyMigrationPlan(input: { revision: string, confirmation: string }): Promise<{ message: string }> {
54
+ const plan = await migrationPlan()
55
+ if (input.revision !== plan.revision)
56
+ throw new MigrationOperationError('The migration plan changed. Refresh and review the new plan before applying it.')
57
+ if (input.confirmation !== `migrate ${plan.environment}`)
58
+ throw new MigrationOperationError(`Type migrate ${plan.environment} to confirm this schema change.`)
59
+ if (!plan.operations.length)
60
+ return { message: 'The database schema is already current.' }
61
+
62
+ const child = Bun.spawn([`${process.cwd()}/buddy`, 'migrate', '--force'], {
63
+ cwd: process.cwd(),
64
+ env: { ...process.env, NO_COLOR: '1' },
65
+ stdout: 'pipe',
66
+ stderr: 'pipe',
67
+ })
68
+ const [stdout, stderr, exitCode] = await Promise.all([
69
+ new Response(child.stdout).text(),
70
+ new Response(child.stderr).text(),
71
+ child.exited,
72
+ ])
73
+ if (exitCode !== 0)
74
+ throw new MigrationOperationError(stderr.trim() || stdout.trim() || 'The migration command failed.')
75
+ return { message: `Applied ${plan.operations.length} planned schema operation${plan.operations.length === 1 ? '' : 's'}.` }
76
+ }
77
+
78
+ export async function reconcileMigrationLedgerPlan(apply = false) {
79
+ return await reconcileMigrationLedger({ dryRun: !apply })
80
+ }
@@ -0,0 +1,11 @@
1
+ import process from 'node:process'
2
+ import { AlertStore, ReleaseStore, resolveAuthEncryptionKey } from '@stacksjs/ts-cloud'
3
+ import { operationsControlPlane } from './control-plane'
4
+
5
+ export function releaseStore(): ReleaseStore {
6
+ return new ReleaseStore(operationsControlPlane().store)
7
+ }
8
+
9
+ export function alertStore(): AlertStore {
10
+ return new AlertStore(operationsControlPlane().store, { encryptionKey: resolveAuthEncryptionKey(process.cwd()) })
11
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { jsonObject, positiveNumber, stringList, stringValue } from './recovery-input'
3
+
4
+ describe('recovery input normalization', () => {
5
+ it('normalizes scalar and list input without accepting nested values', () => {
6
+ expect(stringValue(' backups ')).toBe('backups')
7
+ expect(stringValue({ value: 'backups' })).toBe('')
8
+ expect(stringList('storage/app, public/uploads, ')).toEqual(['storage/app', 'public/uploads'])
9
+ expect(stringList([' storage/app ', null, 'public/uploads'])).toEqual(['storage/app', 'public/uploads'])
10
+ })
11
+
12
+ it('bounds positive recovery objectives and retention values', () => {
13
+ expect(positiveNumber('30', 7, 100)).toBe(30)
14
+ expect(positiveNumber(500, 7, 100)).toBe(100)
15
+ expect(positiveNumber(0, 7, 100)).toBe(7)
16
+ })
17
+
18
+ it('accepts only record-shaped restore metadata', () => {
19
+ expect(jsonObject({ engine: 'postgres' })).toEqual({ engine: 'postgres' })
20
+ expect(jsonObject(['postgres'])).toEqual({})
21
+ })
22
+ })
@@ -0,0 +1,33 @@
1
+ import type { BackupResourceKind, JsonValue } from '@stacksjs/ts-cloud'
2
+
3
+ export const RECOVERY_KINDS: BackupResourceKind[] = [
4
+ 'managed_database',
5
+ 'logical_database',
6
+ 'volume',
7
+ 'files',
8
+ 'control_plane',
9
+ 'infrastructure',
10
+ ]
11
+
12
+ export function stringValue(value: unknown): string {
13
+ return typeof value === 'string' ? value.trim() : ''
14
+ }
15
+
16
+ export function stringList(value: unknown): string[] {
17
+ return Array.isArray(value)
18
+ ? value.map(stringValue).filter(Boolean)
19
+ : typeof value === 'string'
20
+ ? value.split(',').map(entry => entry.trim()).filter(Boolean)
21
+ : []
22
+ }
23
+
24
+ export function positiveNumber(value: unknown, fallback: number, maximum = Number.MAX_SAFE_INTEGER): number {
25
+ const number = Number(value)
26
+ return Number.isFinite(number) && number > 0 ? Math.min(maximum, number) : fallback
27
+ }
28
+
29
+ export function jsonObject(value: unknown): Record<string, JsonValue> {
30
+ if (!value || typeof value !== 'object' || Array.isArray(value))
31
+ return {}
32
+ return value as Record<string, JsonValue>
33
+ }
@@ -0,0 +1,99 @@
1
+ import type {
2
+ BackupDestination,
3
+ BackupPolicy,
4
+ BackupSourceAdapter,
5
+ JsonValue,
6
+ } from '@stacksjs/ts-cloud'
7
+ import { join } from 'node:path'
8
+ import process from 'node:process'
9
+ import {
10
+ AwsDatabaseBackupSource,
11
+ AwsInfrastructureBackupSource,
12
+ BackupCoordinator,
13
+ BackupStore,
14
+ ControlPlaneBackupSource,
15
+ createBackupQueueHandlers,
16
+ DataServiceStore,
17
+ DockerVolumeBackupSource,
18
+ DurableOperationQueue,
19
+ DurableQueueWorker,
20
+ EncryptedDataSecretStore,
21
+ FilesystemBackupSource,
22
+ LogicalDatabaseBackupSource,
23
+ resolveAuthEncryptionKey,
24
+ S3BackupDestinationAdapter,
25
+ } from '@stacksjs/ts-cloud'
26
+ import { operationsControlPlane, operationsEnvironment } from './control-plane'
27
+
28
+ export interface RecoveryRuntime {
29
+ store: BackupStore
30
+ coordinator: BackupCoordinator
31
+ queue: DurableOperationQueue
32
+ worker: DurableQueueWorker
33
+ dataServices: DataServiceStore
34
+ secrets: EncryptedDataSecretStore
35
+ }
36
+
37
+ let runtime: RecoveryRuntime | undefined
38
+
39
+ export function recoveryRuntime(): RecoveryRuntime {
40
+ if (runtime)
41
+ return runtime
42
+
43
+ const controlPlane = operationsControlPlane()
44
+ const store = new BackupStore(controlPlane.store)
45
+ const queue = new DurableOperationQueue(controlPlane.store, { workerId: `stacks-dashboard:${process.pid}` })
46
+ const coordinator = new BackupCoordinator(store, queue)
47
+ const dataServices = new DataServiceStore(controlPlane.store)
48
+ const secrets = new EncryptedDataSecretStore(controlPlane.store, resolveAuthEncryptionKey(process.cwd()))
49
+ const destination = new S3BackupDestinationAdapter(secrets)
50
+
51
+ const resolveSource = (policy: BackupPolicy): BackupSourceAdapter | undefined => {
52
+ if (policy.resourceKind === 'managed_database')
53
+ return new AwsDatabaseBackupSource(dataServices)
54
+ if (policy.resourceKind === 'logical_database')
55
+ return new LogicalDatabaseBackupSource(dataServices, secrets)
56
+ if (policy.resourceKind === 'volume')
57
+ return new DockerVolumeBackupSource()
58
+ if (policy.resourceKind === 'files')
59
+ return new FilesystemBackupSource(process.cwd(), undefined, join(process.cwd(), 'storage', 'cloud', 'restores'))
60
+ if (policy.resourceKind === 'control_plane')
61
+ return new ControlPlaneBackupSource(controlPlane.store, process.cwd(), join(process.cwd(), 'storage', 'cloud', 'restores'))
62
+ if (policy.resourceKind === 'infrastructure')
63
+ return new AwsInfrastructureBackupSource()
64
+ return undefined
65
+ }
66
+
67
+ const handlers = createBackupQueueHandlers({
68
+ store,
69
+ queue,
70
+ resolveSource,
71
+ resolveDestination: (value: BackupDestination) => value.provider === 'aws_backup' ? undefined : destination,
72
+ })
73
+ const worker = new DurableQueueWorker(queue, handlers, {
74
+ parallelism: 2,
75
+ pollIntervalMs: 2_000,
76
+ onError: error => console.error('[dashboard/recovery] worker failed:', error),
77
+ }).start()
78
+
79
+ runtime = { store, coordinator, queue, worker, dataServices, secrets }
80
+ return runtime
81
+ }
82
+
83
+ export function recoveryEnvironment() {
84
+ const controlPlane = operationsControlPlane()
85
+ const environment = operationsEnvironment(controlPlane)
86
+ if (!environment)
87
+ throw new Error('No cloud environment is configured for recovery operations.')
88
+ return { controlPlane, environment }
89
+ }
90
+
91
+ export function safeRecoveryDestination(destination: BackupDestination): Record<string, JsonValue> {
92
+ return {
93
+ ...destination,
94
+ credentialRef: null,
95
+ encryptionKeyRef: null,
96
+ credentialsConfigured: Boolean(destination.credentialRef),
97
+ clientEncryptionConfigured: Boolean(destination.encryptionKeyRef),
98
+ }
99
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { parseSchedulerRegistry, SchedulerOperationError } from './scheduler-operations'
3
+
4
+ describe('scheduler operations', () => {
5
+ it('parses the Buddy scheduler registry marker', () => {
6
+ const tasks = parseSchedulerRegistry([
7
+ 'loading application',
8
+ 'STACKS_SCHEDULE_JSON={"jobs":[{"name":"Cleanup","pattern":"0 * * * *","timezone":"UTC","nextRun":"2026-08-12T12:00:00.000Z","enabled":false}],"locks":[]}',
9
+ ].join('\n'))
10
+
11
+ expect(tasks).toEqual([{
12
+ name: 'Cleanup',
13
+ pattern: '0 * * * *',
14
+ timezone: 'UTC',
15
+ nextRun: '2026-08-12T12:00:00.000Z',
16
+ enabled: false,
17
+ }])
18
+ })
19
+
20
+ it('rejects unmarked output instead of guessing', () => {
21
+ expect(() => parseSchedulerRegistry('{"jobs":[]}')).toThrow(SchedulerOperationError)
22
+ })
23
+ })