@stacksjs/defaults 0.70.365 → 0.70.367

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 (90) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +71 -14
  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/Auth/LoginAction.ts +11 -2
  7. package/app/Actions/Auth/RefreshTokenAction.ts +6 -2
  8. package/app/Actions/Auth/RegisterAction.ts +6 -2
  9. package/app/Actions/Auth/VerifyTwoFactorLoginAction.ts +6 -2
  10. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
  11. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
  12. package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
  13. package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
  14. package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
  15. package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
  16. package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
  17. package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
  18. package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
  19. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
  20. package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
  21. package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
  22. package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
  23. package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
  24. package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
  25. package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
  26. package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
  27. package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
  28. package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
  29. package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
  30. package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
  31. package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
  32. package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
  33. package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
  34. package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
  35. package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
  36. package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
  37. package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
  38. package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
  39. package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
  40. package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
  41. package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
  42. package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
  43. package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
  44. package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
  45. package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
  46. package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
  47. package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
  48. package/functions/deployments.ts +18 -0
  49. package/functions/operations.ts +361 -0
  50. package/ide/vscode/package.json +1 -1
  51. package/package.json +1 -1
  52. package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +0 -2
  53. package/resources/components/Dashboard/Auth/LoginDashboard.stx +1 -1
  54. package/resources/components/Dashboard/Auth/RegisterDashboard.stx +1 -3
  55. package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
  56. package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
  57. package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
  58. package/resources/components/Dashboard/GlobalSearch.stx +87 -91
  59. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +9 -435
  60. package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +416 -0
  61. package/resources/components/Dashboard/Modals/BaseModal.stx +13 -36
  62. package/resources/components/Dashboard/Modals/Popups/Alert.stx +18 -44
  63. package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
  64. package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
  65. package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
  66. package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
  67. package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
  68. package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
  69. package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
  70. package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
  71. package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
  72. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
  73. package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
  74. package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
  75. package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
  76. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
  77. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
  78. package/resources/components/Dashboard/UI/Modal.stx +43 -14
  79. package/resources/components/Dashboard/UI/Table.stx +30 -25
  80. package/resources/functions/dashboard/sidebar.ts +1 -0
  81. package/routes/dashboard-api.ts +23 -0
  82. package/routes/dashboard.ts +1 -0
  83. package/views/dashboard/AUDIT.md +21 -16
  84. package/views/dashboard/operations/audit.stx +5 -0
  85. package/views/dashboard/operations/changes.stx +5 -0
  86. package/views/dashboard/operations/incidents.stx +5 -0
  87. package/views/dashboard/operations/migrations.stx +8 -0
  88. package/views/dashboard/operations/recovery.stx +8 -0
  89. package/views/dashboard/operations/scheduler.stx +8 -0
  90. package/views/dashboard/stores/ci.ts +2 -2
@@ -0,0 +1,90 @@
1
+ import type { BackupResourceKind } from '@stacksjs/ts-cloud'
2
+ import type { RequestInstance } from '@stacksjs/types'
3
+ import { Action } from '@stacksjs/actions'
4
+ import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
6
+ import { appendOperatorEvent } from './control-plane'
7
+ import { positiveNumber, RECOVERY_KINDS, stringList, stringValue } from './recovery-input'
8
+ import { recoveryEnvironment, recoveryRuntime } from './recovery-runtime'
9
+
10
+ export default new Action({
11
+ name: 'RecoveryPolicyStoreAction',
12
+ description: 'Creates a scheduled native recovery policy for a configured source and destination.',
13
+ method: 'POST',
14
+ apiResponse: true,
15
+ async handle(request: RequestInstance) {
16
+ const input = request.all() as Record<string, unknown>
17
+ const name = stringValue(input.name)
18
+ const destinationId = stringValue(input.destinationId)
19
+ const resourceKind = stringValue(input.resourceKind) as BackupResourceKind
20
+ const includePatterns = stringList(input.includePatterns)
21
+ const excludePatterns = stringList(input.excludePatterns)
22
+ if (!name)
23
+ return response.json({ message: 'A policy name is required.' }, 422)
24
+ if (!RECOVERY_KINDS.includes(resourceKind))
25
+ return response.json({ message: 'Choose a supported recovery source.' }, 422)
26
+
27
+ const runtime = recoveryRuntime()
28
+ const { controlPlane, environment } = recoveryEnvironment()
29
+ const destination = runtime.store.getDestination(destinationId)
30
+ if (!destination || destination.projectId !== controlPlane.project.id)
31
+ return response.json({ message: 'Choose a valid recovery destination.' }, 422)
32
+ if (destination.provider === 'aws_backup' && resourceKind !== 'infrastructure')
33
+ return response.json({ message: 'AWS Backup destinations require an infrastructure recovery policy.' }, 422)
34
+
35
+ const resourceId = stringValue(input.resourceId) || undefined
36
+ const dataServiceId = stringValue(input.dataServiceId) || undefined
37
+ if (['managed_database', 'logical_database'].includes(resourceKind)) {
38
+ const service = dataServiceId ? runtime.dataServices.get(dataServiceId) : undefined
39
+ if (!service || service.projectId !== controlPlane.project.id || service.environmentId !== environment.id)
40
+ return response.json({ message: 'Database recovery policies require a data service in this environment.' }, 422)
41
+ }
42
+ if (['volume', 'files'].includes(resourceKind)) {
43
+ const resource = resourceId ? controlPlane.store.getResource(resourceId) : undefined
44
+ if (!resource || resource.projectId !== controlPlane.project.id || resource.environmentId !== environment.id)
45
+ return response.json({ message: 'This recovery source requires a resource in the current environment.' }, 422)
46
+ }
47
+ if (resourceKind === 'volume' && includePatterns.length === 0)
48
+ return response.json({ message: 'Volume policies require the Docker volume name.' }, 422)
49
+ if (resourceKind === 'files' && includePatterns.length === 0)
50
+ return response.json({ message: 'File policies require at least one project-relative include path.' }, 422)
51
+ if (resourceKind === 'infrastructure' && (!includePatterns.some(value => value.startsWith('resource:arn:')) || !includePatterns.some(value => value.startsWith('role:arn:'))))
52
+ return response.json({ message: 'Infrastructure policies require resource:arn: and role:arn: include entries.' }, 422)
53
+
54
+ try {
55
+ const policy = runtime.store.createPolicy({
56
+ organizationId: controlPlane.organization.id,
57
+ projectId: controlPlane.project.id,
58
+ environmentId: environment.id,
59
+ resourceId,
60
+ dataServiceId,
61
+ destinationId: destination.id,
62
+ name,
63
+ resourceKind,
64
+ schedule: stringValue(input.schedule) || 'daily',
65
+ timezone: stringValue(input.timezone) || 'UTC',
66
+ retention: {
67
+ keepLast: positiveNumber(input.keepLast, 7, 10_000),
68
+ expireAfterDays: positiveNumber(input.expireAfterDays, 30, 36_500),
69
+ },
70
+ compression: ['none', 'gzip', 'zstd'].includes(stringValue(input.compression))
71
+ ? stringValue(input.compression) as 'none' | 'gzip' | 'zstd'
72
+ : 'gzip',
73
+ encryption: 'destination',
74
+ includePatterns,
75
+ excludePatterns,
76
+ expectedRpoMinutes: positiveNumber(input.expectedRpoMinutes, 1_440, 525_600),
77
+ expectedRtoMinutes: positiveNumber(input.expectedRtoMinutes, 120, 525_600),
78
+ enabled: input.enabled !== false,
79
+ })
80
+ await appendOperatorEvent(request, 'backup.policy_created', {
81
+ policyId: policy.id,
82
+ resourceKind: policy.resourceKind,
83
+ }, policy.resourceId)
84
+ return response.json({ success: true, policy }, 201)
85
+ }
86
+ catch (error) {
87
+ return dashboardOperationalError(error, 'The recovery policy could not be created.', 'RecoveryPolicyStoreAction', 422)
88
+ }
89
+ },
90
+ })
@@ -0,0 +1,32 @@
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: 'RecoveryProtectAction',
9
+ description: 'Pins or holds one recovery point against retention cleanup.',
10
+ method: 'PATCH',
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
+ const input = request.all() as Record<string, unknown>
19
+ if (typeof input.pinned !== 'boolean' && typeof input.held !== 'boolean')
20
+ return response.json({ message: 'Set pinned or held to a boolean value.' }, 422)
21
+ const updated = runtime.store.updateRecoveryPoint(point.id, {
22
+ pinned: typeof input.pinned === 'boolean' ? input.pinned : point.pinned,
23
+ held: typeof input.held === 'boolean' ? input.held : point.held,
24
+ })
25
+ await appendOperatorEvent(request, 'backup.protection_updated', {
26
+ recoveryPointId: point.id,
27
+ pinned: updated.pinned,
28
+ held: updated.held,
29
+ }, point.resourceId)
30
+ return { success: true, recoveryPoint: updated }
31
+ },
32
+ })
@@ -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
+ })