feat: prepare six-lesson trial backend with reliable rewards and operations
Backend CI / type-check, build, tests (push) Successful in 1m0s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 14:35:42 +03:30
co-authored by Cursor
parent 5e5a14030a
commit dfb20ca92c
46 changed files with 2589 additions and 123 deletions
+280
View File
@@ -0,0 +1,280 @@
# Starts Child Gold Land backend + frontend for local dev.
# Manages only processes started by this script. No npm install / rebuild on each run.
#
# Usage:
# pwsh -File .\scripts\dev-local-stack.ps1
# pwsh -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest # alt ports + health/login probe then exit
#
# Default ports: backend 3000, frontend 5174. Test ports: 3010 / 5184 (when 3000/5174 are busy).
param(
[switch]$UseTestPorts,
[switch]$SmokeTest
)
$ErrorActionPreference = 'Stop'
$BackendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$WorkspaceRoot = (Resolve-Path (Join-Path $BackendRoot '..')).Path
$FrontendRoot = Join-Path $WorkspaceRoot 'frontend'
if ($UseTestPorts) {
$BackendPort = 3010
$FrontendPort = 5190
} else {
$BackendPort = 3000
$FrontendPort = 5174
}
$HealthUrl = "http://127.0.0.1:${BackendPort}/health"
$FrontendUrl = "http://localhost:${FrontendPort}/"
$LoginProxyUrl = "http://localhost:${FrontendPort}/api/auth/login"
$script:ManagedProcesses = @()
$script:TempDbDir = $null
function Write-Info([string]$Message) { Write-Host $Message -ForegroundColor Cyan }
function Write-Warn([string]$Message) { Write-Host $Message -ForegroundColor Yellow }
function Write-Err([string]$Message) { Write-Host $Message -ForegroundColor Red }
function Test-NodeEngines {
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
throw 'Node.js not found in PATH. Install Node ^22.18.0 or >=24.12.0 (see .nvmrc).'
}
$raw = (node -v).TrimStart('v')
$ver = [version]$raw
$ok = ($ver.Major -eq 22 -and $ver -ge [version]'22.18.0') -or ($ver.Major -ge 24 -and $ver -ge [version]'24.12.0')
if (-not $ok) {
throw "Node v$raw is not supported. Required: ^22.18.0 or >=24.12.0. Run: nvm install 22.18.0 && nvm use 22.18.0"
}
Write-Info "Node $(node -v) at $((Get-Command node).Source)"
}
function Test-RepoLayout {
if (-not (Test-Path (Join-Path $BackendRoot 'package.json'))) {
throw "Backend package.json not found at $BackendRoot"
}
if (-not (Test-Path (Join-Path $FrontendRoot 'package.json'))) {
throw "Frontend not found at `"$FrontendRoot`" (expected sibling of backend-nest)."
}
}
function Test-DependenciesPresent {
if (-not (Test-Path (Join-Path $BackendRoot 'node_modules'))) {
throw "Backend node_modules missing. Run once: cd `"$BackendRoot`"; npm install"
}
if (-not (Test-Path (Join-Path $FrontendRoot 'node_modules'))) {
throw "Frontend node_modules missing. Run once: cd `"$FrontendRoot`"; npm install"
}
}
function Get-ListenProcessId([int]$Port) {
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
if ($conn) { return [int]$conn.OwningProcess }
return $null
}
function Assert-PortFree([int]$Port, [string]$Label) {
$ownerPid = Get-ListenProcessId $Port
if ($null -ne $ownerPid) {
$name = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName
throw "${Label} port $Port is in use (PID $ownerPid, $name). Use -UseTestPorts or free the port."
}
}
function New-BackendProcessEnvironment {
$envMap = @{}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$envMap[$entry.Key] = [string]$entry.Value
}
$envMap['PORT'] = [string]$BackendPort
$envMap['HOST'] = '0.0.0.0'
$envMap['NODE_ENV'] = 'development'
$envMap['CORS_ORIGIN'] = "http://localhost:${FrontendPort}"
if ($UseTestPorts) {
$script:TempDbDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cgl-devstack-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $script:TempDbDir -Force | Out-Null
$envMap['DB_PATH'] = Join-Path $script:TempDbDir 'app.db'
Write-Info "Test backend DB: $($envMap['DB_PATH'])"
}
return $envMap
}
function Start-ProcessWithEnv {
param(
[string]$FilePath,
[string[]]$ArgumentList,
[string]$WorkingDirectory,
[hashtable]$Environment
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $FilePath
$psi.WorkingDirectory = $WorkingDirectory
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$quoted = $ArgumentList | ForEach-Object {
if ($_ -match '\s') { "`"$_`"" } else { $_ }
}
$psi.Arguments = $quoted -join ' '
}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$psi.EnvironmentVariables[[string]$entry.Key] = [string]$entry.Value
}
foreach ($key in $Environment.Keys) {
$psi.EnvironmentVariables[$key] = [string]$Environment[$key]
}
$proc = [System.Diagnostics.Process]::Start($psi)
if (-not $proc) {
throw "Failed to start process: $FilePath"
}
return $proc
}
function Start-Backend {
$mainJs = Join-Path $BackendRoot 'dist\main.js'
if (-not (Test-Path $mainJs)) {
throw "Missing dist/main.js. Run once: cd `"$BackendRoot`"; npm run build"
}
$node = (Get-Command node).Source
$envMap = New-BackendProcessEnvironment
Write-Info "Starting backend on port $BackendPort ..."
$p = Start-ProcessWithEnv -FilePath $node -ArgumentList @($mainJs) -WorkingDirectory $BackendRoot -Environment $envMap
$script:ManagedProcesses += $p
Start-Sleep -Milliseconds 500
if ($p.HasExited) {
throw "Backend exited immediately (exit $($p.ExitCode)). Build with current Node and check dist/main.js."
}
return $p
}
function Start-Frontend {
$npm = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source
if (-not $npm) {
$npm = (Get-Command npm -ErrorAction SilentlyContinue).Source
}
if (-not $npm) {
throw 'npm not found in PATH.'
}
$envMap = @{}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$envMap[$entry.Key] = [string]$entry.Value
}
$envMap['VITE_DEV_PORT'] = [string]$FrontendPort
$envMap['VITE_DEV_API_TARGET'] = "http://127.0.0.1:${BackendPort}"
Write-Info "Starting frontend on port $FrontendPort (proxy -> $($envMap['VITE_DEV_API_TARGET'])) ..."
$p = Start-ProcessWithEnv -FilePath $npm -ArgumentList @('run', 'dev', '--', '--port', [string]$FrontendPort) `
-WorkingDirectory $FrontendRoot -Environment $envMap
$script:ManagedProcesses += $p
return $p
}
function Wait-BackendHealth {
param([int]$TimeoutSeconds = 90)
Write-Info "Waiting for backend health (max ${TimeoutSeconds}s) ..."
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
foreach ($p in $script:ManagedProcesses) {
if ($p.HasExited) {
throw "Managed process exited early (PID $($p.Id), exit $($p.ExitCode))."
}
}
try {
$r = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 3
if ($r.StatusCode -eq 200) {
Write-Info "Backend health OK: $HealthUrl"
return
}
} catch {
Start-Sleep -Seconds 2
}
}
throw "Backend health timed out: $HealthUrl"
}
function Wait-FrontendHttp {
param([int]$TimeoutSeconds = 120)
Write-Info "Waiting for frontend (max ${TimeoutSeconds}s) ..."
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
try {
$r = Invoke-WebRequest -Uri $FrontendUrl -UseBasicParsing -TimeoutSec 3
if ($r.StatusCode -eq 200) {
Write-Info "Frontend OK: $FrontendUrl"
return
}
} catch {
Start-Sleep -Seconds 2
}
}
throw "Frontend HTTP timed out: $FrontendUrl"
}
function Test-ProxyLogin {
$body = '{"username":"demo","password":"123456"}'
$r = Invoke-WebRequest -Uri $LoginProxyUrl -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing
if ($r.StatusCode -ne 200) {
throw "Proxy login failed: HTTP $($r.StatusCode)"
}
Write-Info "Proxy login OK: $LoginProxyUrl"
}
function Stop-ManagedProcesses {
foreach ($p in $script:ManagedProcesses) {
if ($p -and -not $p.HasExited) {
Write-Info "Stopping PID $($p.Id) ..."
try {
& taskkill.exe /PID $p.Id /T /F 2>$null | Out-Null
} catch {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
}
}
$script:ManagedProcesses = @()
if ($script:TempDbDir -and (Test-Path $script:TempDbDir)) {
Remove-Item -Recurse -Force $script:TempDbDir -ErrorAction SilentlyContinue
$script:TempDbDir = $null
}
}
try {
Write-Info 'Child Gold Land — local dev stack'
if ($UseTestPorts) { Write-Warn 'Using test ports 3010 / 5190 (temp SQLite when backend starts fresh DB).' }
Test-NodeEngines
Test-RepoLayout
Test-DependenciesPresent
Assert-PortFree -Port $BackendPort -Label 'Backend'
Assert-PortFree -Port $FrontendPort -Label 'Frontend'
Start-Backend | Out-Null
Start-Frontend | Out-Null
Wait-BackendHealth
if ($SmokeTest) {
Wait-FrontendHttp
Test-ProxyLogin
Write-Info 'SmokeTest passed.'
exit 0
}
Write-Host ''
Write-Info "Frontend: $FrontendUrl"
Write-Info "Backend health: $HealthUrl"
Write-Info 'Demo login: demo / 123456'
Write-Warn 'Press Ctrl+C to stop only processes started by this script.'
Write-Host ''
while ($true) {
Start-Sleep -Seconds 2
foreach ($p in $script:ManagedProcesses) {
if ($p.HasExited) {
Write-Err "Process PID $($p.Id) exited unexpectedly (code $($p.ExitCode))."
exit 1
}
}
}
} catch {
Write-Err $_.Exception.Message
exit 1
} finally {
Stop-ManagedProcesses
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Validates that a SQLite file matches Child Gold Land backend schema expectations.
*/
import Database from 'better-sqlite3'
export const EXPECTED_TABLES = [
'users',
'user_level_progress',
'reward_ledger',
'auth_accounts',
]
/** Keep in sync with src/database/migrations/index.ts */
export const EXPECTED_MIGRATION_IDS = ['001_initial_schema']
/**
* @param {string} dbPath
* @returns {{ ok: true } | { ok: false; reason: string }}
*/
export function validateAppSqliteFile(dbPath) {
let db
try {
db = new Database(dbPath, { readonly: true })
} catch (error) {
return { ok: false, reason: `cannot open database: ${error instanceof Error ? error.message : error}` }
}
try {
const integrity = db.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
return { ok: false, reason: `integrity_check failed: ${integrity}` }
}
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'",
)
.all()
.map((r) => r.name)
for (const name of EXPECTED_TABLES) {
if (!tables.includes(name)) {
return { ok: false, reason: `missing table: ${name}` }
}
}
const migTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
.get()
if (!migTable) {
return { ok: false, reason: 'missing schema_migrations table' }
}
const applied = db.prepare('SELECT id FROM schema_migrations ORDER BY id').all().map((r) => r.id)
for (const id of EXPECTED_MIGRATION_IDS) {
if (!applied.includes(id)) {
return { ok: false, reason: `missing migration: ${id}` }
}
}
return { ok: true }
} finally {
db.close()
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* WAL-safe SQLite backup via better-sqlite3 backup API (not a raw file copy).
* Usage: DB_PATH=./data/app.db BACKUP_DIR=./backups node scripts/sqlite-backup.mjs
*/
import Database from 'better-sqlite3'
import { mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
const srcPath = process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db')
const backupDir = process.env.BACKUP_DIR ?? join(process.cwd(), 'backups')
const retentionDays = Number(process.env.BACKUP_RETENTION_DAYS ?? '0')
if (!existsSync(srcPath)) {
console.error(`Source database not found: ${srcPath}`)
process.exit(1)
}
mkdirSync(backupDir, { recursive: true })
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const destPath = join(backupDir, `app-${stamp}.db`)
const src = new Database(srcPath, { readonly: true })
try {
await src.backup(destPath)
} finally {
src.close()
}
const verify = new Database(destPath, { readonly: true })
try {
const integrity = verify.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
console.error(`Backup integrity check failed: ${integrity}`)
process.exit(1)
}
} finally {
verify.close()
}
console.log(`Backup written: ${destPath}`)
if (retentionDays > 0) {
const cutoff = Date.now() - retentionDays * 86_400_000
for (const name of readdirSync(backupDir)) {
if (!name.startsWith('app-') || !name.endsWith('.db')) continue
const full = join(backupDir, name)
if (statSync(full).mtimeMs < cutoff) {
unlinkSync(full)
console.log(`Removed old backup: ${name}`)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Restore backup to a target path (service must be stopped).
* Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>
*/
import Database from 'better-sqlite3'
import {
copyFileSync,
existsSync,
mkdirSync,
renameSync,
rmSync,
statSync,
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { validateAppSqliteFile } from './sqlite-app-schema.mjs'
const backupPath = resolve(process.argv[2] ?? '')
const targetPath = resolve(process.argv[3] ?? '')
if (!backupPath || !targetPath) {
console.error('Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>')
process.exit(1)
}
if (!existsSync(backupPath)) {
console.error(`Backup not found: ${backupPath}`)
process.exit(1)
}
const activeDb = resolve(process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db'))
const targetExists = existsSync(targetPath)
if (targetPath === activeDb) {
console.error('Restore onto active DB_PATH is not allowed while it is configured as the live database.')
console.error('Stop the backend service, point DB_PATH elsewhere, or restore to a temp file first.')
process.exit(1)
}
if (targetExists && process.env.RESTORE_ACK_STOPPED !== 'YES') {
console.error(
'Target database file already exists. Stop the backend service and set RESTORE_ACK_STOPPED=YES to continue.',
)
process.exit(1)
}
const schema = validateAppSqliteFile(backupPath)
if (!schema.ok) {
console.error(`Backup rejected: ${schema.reason}`)
process.exit(1)
}
const staging = `${targetPath}.restore-staging`
const stagingWal = `${staging}-wal`
const stagingShm = `${staging}-shm`
function cleanupStaging() {
for (const p of [staging, stagingWal, stagingShm]) {
try {
if (existsSync(p)) rmSync(p)
} catch {
// ignore
}
}
}
cleanupStaging()
try {
copyFileSync(backupPath, staging)
const stagedCheck = validateAppSqliteFile(staging)
if (!stagedCheck.ok) {
console.error(`Staging validation failed: ${stagedCheck.reason}`)
cleanupStaging()
process.exit(1)
}
const verify = new Database(staging, { readonly: true })
try {
const integrity = verify.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
console.error(`Staging integrity check failed: ${integrity}`)
cleanupStaging()
process.exit(1)
}
} finally {
verify.close()
}
for (const sidecar of [`${targetPath}-wal`, `${targetPath}-shm`]) {
try {
if (existsSync(sidecar)) rmSync(sidecar)
} catch {
// ignore
}
}
if (targetExists) {
const backupOfTarget = `${targetPath}.pre-restore-${Date.now()}.bak`
renameSync(targetPath, backupOfTarget)
console.log(`Existing target moved to ${backupOfTarget}`)
}
renameSync(staging, targetPath)
console.log(`Restored ${backupPath} -> ${targetPath}`)
} catch (error) {
cleanupStaging()
console.error(error instanceof Error ? error.message : error)
process.exit(1)
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Smoke-test production env on a temp DB (no deploy).
* Requires: npm run build first.
*/
import { spawn, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
if (!existsSync(join(backendRoot, 'dist', 'main.js'))) {
console.error('Run npm run build before trial-production-smoke.mjs')
process.exit(1)
}
const tempDir = mkdtempSync(join(tmpdir(), 'cgl-prod-smoke-'))
const dbPath = join(tempDir, 'app.db')
const port = String(3020 + Math.floor(Math.random() * 100))
const secret = `trial-smoke-${'x'.repeat(40)}`
function fail(msg) {
console.error(msg)
rmSync(tempDir, { recursive: true, force: true })
process.exit(1)
}
const bad = spawnSync(process.execPath, ['dist/main.js'], {
cwd: backendRoot,
env: {
...process.env,
NODE_ENV: 'production',
PORT: port,
DB_PATH: dbPath,
JWT_SECRET: 'gold-land-dev-secret-change-in-production',
CORS_ORIGIN: 'http://127.0.0.1:8080',
},
stdio: 'pipe',
encoding: 'utf8',
timeout: 8000,
})
if (bad.status === 0) {
fail('Expected startup failure with dev JWT placeholder in production')
}
async function waitForReady() {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['dist/main.js'], {
cwd: backendRoot,
env: {
...process.env,
NODE_ENV: 'production',
PORT: port,
DB_PATH: dbPath,
JWT_SECRET: secret,
CORS_ORIGIN: 'http://127.0.0.1:8080',
},
stdio: 'pipe',
})
const timer = setTimeout(() => {
child.kill()
reject(new Error('startup timeout'))
}, 20_000)
const poll = async () => {
try {
const live = await fetch(`http://127.0.0.1:${port}/health`)
const ready = await fetch(`http://127.0.0.1:${port}/health/ready`)
if (live.ok && ready.ok) {
clearTimeout(timer)
child.kill()
resolve(undefined)
return
}
} catch {
// retry
}
setTimeout(poll, 300)
}
setTimeout(poll, 500)
})
}
await waitForReady().catch((e) => fail(String(e)))
const demoCheck = spawnSync(
process.execPath,
[
'-e',
`const D=require('better-sqlite3');const d=new D(${JSON.stringify(dbPath)});const row=d.prepare("SELECT COUNT(*) as c FROM auth_accounts WHERE username='demo'").get();console.log(row.c);d.close();`,
],
{ cwd: backendRoot, encoding: 'utf8' },
)
const demoCount = Number(demoCheck.stdout.trim())
if (demoCount !== 0) {
fail(`Expected no demo account in production DB, found ${demoCount}`)
}
rmSync(tempDir, { recursive: true, force: true })
console.log('trial-production-smoke passed')