platform-codebase/features/platform-seed/bin/generate-dev-data.ts
2026-03-25 23:56:46 -07:00

471 lines
17 KiB
TypeScript

import { parseArgs } from 'node:util'
import { log, logError } from '../src/lib/http'
import { clearSessionCache, getOrCreateSession, ssoAuthHeader } from '../src/lib/auth'
import { resetNameTracking } from '../src/factory/names'
import { bootstrapTables } from '../src/lib/bootstrap-tables'
import { phase1Users } from '../src/phases/phase1-users'
import { phase2AttrDefs } from '../src/phases/phase2-attr-defs'
import { phase3Profiles } from '../src/phases/phase3-profiles'
import { phase4Attributes } from '../src/phases/phase4-attr-values'
import { phase5Messaging } from '../src/phases/phase5-messaging'
import { phase6Reviews } from '../src/phases/phase6-reviews'
import { phase7Streaming } from '../src/phases/phase7-streaming'
import { phase8Merchant } from '../src/phases/phase8-merchant'
import { phase9Marketplace } from '../src/phases/phase9-marketplace'
import { phase10Analytics } from '../src/phases/phase10-analytics'
import { phase11Payments } from '../src/phases/phase11-payments'
import { phase12Config } from '../src/phases/phase12-config'
import { phaseBrowsing } from '../src/phases/phase-browsing'
import { makeBrowsingScenario } from '../src/factory/make-browsing'
import { createRng } from '../src/lib/rng'
import { verifyClientSession, verifyProviderSession, printVerificationResults } from '../src/lib/session-verifier'
import { runSimulation } from '../src/factory/make-simulation'
import type { UserTimeline } from '../src/factory/make-simulation'
import { runReset } from '../src/phases/reset'
import { pullAttrs } from '../src/sync/pull-attrs'
import { pushAttrs } from '../src/sync/push-attrs'
import { diffAttrs } from '../src/sync/diff-attrs'
import {
withDb, ANALYTICS_DB, MESSAGING_DB, REVIEWS_DB,
PAYMENTS_DB, STREAMING_DB, MERCHANT_DB, MARKETPLACE_DB, FEATURE_FLAGS_DB,
TRUST_DB, SSO_DB,
} from '../src/lib/db'
import type { SeedContext, SeededClient, SeededProvider } from '../src/factory/types'
import type { GeneratedUser } from '../src/factory/types'
const { values } = parseArgs({
options: {
all: { type: 'boolean', default: false },
phase: { type: 'string' },
scenario: { type: 'string', default: 'standard' },
'sync-attrs': { type: 'string' },
status: { type: 'boolean', default: false },
reset: { type: 'boolean', default: false },
verify: { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
strict: false,
})
// ── Phase definitions ──
type PhaseId = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12' | 'browse'
const PHASE_DEPS: Record<PhaseId, PhaseId[]> = {
'1': [],
'2': ['1'],
'3': ['1'],
'4': ['1', '2', '3'],
'11': ['1'],
'9': ['1', '11'], // Subscriptions need payment methods
'browse': ['1', '3', '9'], // Browsing needs users + profiles + subscriptions
'5': ['1', 'browse'], // Messaging after browsing (quota pre-consumed)
'6': ['1', '5'],
'7': ['1', '11'],
'8': ['1'],
'10': ['1', '3', 'browse'], // Analytics derived from browsing
'12': ['1'],
}
const ALL_PHASE_ORDER: PhaseId[] = [
'1', '2', '3', '4',
'11', '9', // Payment methods then subscriptions
'browse', // Client browsing (search + quota + views + clicks)
'5', '6', // Messaging + reviews (after quota consumed)
'7', '8', '10', '12', // Streaming, merchant, analytics, config
]
const PHASE_NAMES: Record<PhaseId, string> = {
'1': 'Users (SSO register/login)',
'2': 'Attribute definitions',
'3': 'Provider profiles',
'4': 'Attribute values',
'browse': 'Client browsing (search + quota + views)',
'5': 'Messaging threads',
'6': 'Reviews',
'7': 'Streaming sessions',
'8': 'Merchant stores',
'9': 'Marketplace subscriptions + regions',
'10': 'Analytics historical ingest',
'11': 'Payment methods',
'12': 'Feature flags + trust + cost metrics',
}
// ── Scenario loading ──
async function loadScenario(name: string): Promise<{ providers: GeneratedUser[]; clients: GeneratedUser[] }> {
try {
const mod = await import(`../src/scenarios/${name}.ts`) as {
ALL_PROVIDERS: GeneratedUser[]
ALL_CLIENTS: GeneratedUser[]
}
return { providers: mod.ALL_PROVIDERS, clients: mod.ALL_CLIENTS }
} catch (err) {
logError(`Failed to load scenario "${name}": ${(err as Error).message}`)
logError('Available scenarios: standard, minimal')
process.exit(1)
}
}
// ── Phase runner ──
async function runPhase(phase: PhaseId, ctx: SeedContext, scenario: { providers: GeneratedUser[]; clients: GeneratedUser[] }): Promise<SeedContext> {
switch (phase) {
case '1': {
const { providers, clients, admins } = await phase1Users(scenario.providers, scenario.clients, ctx.timelines as UserTimeline[])
ctx.providers = providers
ctx.clients = clients
ctx.admins = admins
break
}
case '2':
await phase2AttrDefs(ctx.admins)
break
case '3':
ctx.providers = await phase3Profiles(ctx.providers)
break
case '4':
await phase4Attributes(ctx.providers, ctx.clients)
break
case 'browse': {
// Build tier assignment map — only active subscribers can browse.
// Expired/cancelled clients are excluded (no tier = no browsing).
// Matches prod: every client must subscribe to use the platform.
const tierMap = new Map<number, string>()
for (let i = 0; i < ctx.clients.length; i++) {
const browseRng = createRng(0xb2035e + i)
// ~75% active (can browse), ~25% expired/cancelled (can't)
const isActive = browseRng.next() < 0.75
if (!isActive) continue
const tier = browseRng.weighted([
{ val: 'bronze', w: 35 },
{ val: 'silver', w: 25 },
{ val: 'gold', w: 20 },
{ val: 'platinum', w: 12 },
{ val: 'iridium', w: 8 },
])
tierMap.set(i, tier)
}
const browsingRng = createRng(0xb2035e)
ctx.browsingSessions = makeBrowsingScenario(ctx.clients, ctx.providers, tierMap, browsingRng)
await phaseBrowsing(ctx.providers, ctx.clients, ctx.browsingSessions)
break
}
case '5':
ctx.threads = await phase5Messaging(ctx.providers, ctx.clients)
break
case '6':
await phase6Reviews(ctx.providers, ctx.clients)
break
case '7':
await phase7Streaming(ctx.providers, ctx.clients, ctx.paymentMethodIds, ctx.timelines as UserTimeline[])
break
case '8':
await phase8Merchant(ctx.providers)
break
case '9':
await phase9Marketplace(ctx.admins, ctx.providers, ctx.clients, ctx.timelines as UserTimeline[])
break
case '10':
await phase10Analytics(ctx.admins, ctx.providers, ctx.clients, ctx.timelines as UserTimeline[])
break
case '11':
ctx.paymentMethodIds = await phase11Payments(ctx.clients)
break
case '12':
await phase12Config(ctx.admins, ctx.providers, ctx.clients)
break
}
return ctx
}
// ── Status check ──
async function runStatus(): Promise<void> {
log('\n═══ Status Check ═══')
// Check API services (ports from services.yaml)
const services = [
{ name: 'SSO', port: 4001, path: '/auth/me' },
{ name: 'Profile', port: 3110, path: '/provider-profiles' },
{ name: 'Attributes', port: 3015, path: '/attribute-definitions?entityType=user' },
{ name: 'Messaging', port: 3120, path: '/api/messaging/threads' },
{ name: 'Reviews', port: 3030, path: '/api/reviews/reviews' },
{ name: 'Streaming', port: 3130, path: '/api/sessions' },
{ name: 'Payments', port: 3600, path: '/payment-methods' },
{ name: 'Merchant', port: 3020, path: '/stores' },
{ name: 'Marketplace', port: 3001, path: '/api/tiers' },
{ name: 'Analytics', port: 3012, path: '/api/profile-analytics' },
{ name: 'Trust', port: 3032, path: '/api/verifications' },
]
for (const svc of services) {
try {
const res = await fetch(`http://localhost:${svc.port}${svc.path}`)
// Any HTTP response (even 401/404/500) means the service is running
log(` ${svc.name} (${svc.port}): ✓ reachable (HTTP ${res.status})`)
} catch {
log(` ${svc.name} (${svc.port}): ✗ unreachable`)
}
}
// Check databases
const dbChecks: Array<{ name: string; config: typeof ANALYTICS_DB; tables: string[] }> = [
{ name: 'Analytics', config: ANALYTICS_DB, tables: ['profile_events', 'cost_entries'] },
{ name: 'Messaging', config: MESSAGING_DB, tables: ['messaging_threads', 'messaging_messages'] },
{ name: 'Reviews', config: REVIEWS_DB, tables: ['provider_reviews', 'client_reviews'] },
{ name: 'Payments', config: PAYMENTS_DB, tables: ['payment_methods', 'subscriptions', 'transactions'] },
{ name: 'Streaming', config: STREAMING_DB, tables: ['stream_sessions', 'stream_tips'] },
{ name: 'Merchant', config: MERCHANT_DB, tables: ['provider_stores', 'merchant_products'] },
{ name: 'Marketplace', config: MARKETPLACE_DB, tables: ['marketplace_platform_subscription_tiers', 'marketplace_regions'] },
{ name: 'FeatureFlags', config: FEATURE_FLAGS_DB, tables: ['feature_flags'] },
{ name: 'Trust', config: TRUST_DB, tables: ['verification_proofs'] },
]
for (const check of dbChecks) {
try {
await withDb(check.config, async (client) => {
for (const table of check.tables) {
try {
const result = await client.query(`SELECT count(*) FROM ${table}`)
log(` ${check.name}.${table}: ${result.rows[0].count} rows`)
} catch {
log(` ${check.name}.${table}: table not found`)
}
}
})
} catch {
log(` ${check.name} DB (${check.config.port}): ✗ unreachable`)
}
}
}
// ── Help ──
function printUsage(): void {
log(`
Lilith Platform Dev Data Generator (Factory-Based)
Usage:
bun run features/platform-seed/bin/generate-dev-data.ts [options]
Options:
--all Run all 12 phases in dependency order
--phase=N Run a specific phase (1-12) with auto-resolved dependencies
--scenario=NAME Select scenario: standard (default), minimal
--sync-attrs=MODE Attribute sync: pull, push, or diff
--status Check service reachability + database row counts
--reset Truncate all seeded data (preserves SSO users)
--help Show this help
Phases:
1 User registration (SSO API) [no deps]
2 Attribute definitions (Attributes API) [no deps]
3 Provider profiles (Profile API) [→ 1]
4 Attribute values (Attributes API) [→ 1, 2, 3]
5 Messaging threads (Messaging API) [→ 1]
6 Reviews (Reviews API) [→ 1, 5]
7 Streaming sessions (Streaming API) [→ 1, 11]
8 Merchant stores (Merchant API) [→ 1]
9 Marketplace subs + regions (API + DB) [→ 1]
10 Analytics ingest (Analytics API) [→ 1, 3]
11 Payment methods (Payments API) [→ 1]
12 Feature flags + trust + costs (API+DB) [no deps]
Scenarios:
standard ~99 providers, ~205 clients, 5 verticals, 7+ cities
minimal ~15 providers, ~30 clients for CI
`)
}
// ── Main ──
async function main(): Promise<void> {
if (values.help) {
printUsage()
return
}
if (values.status) {
await runStatus()
return
}
if (values.reset) {
await runReset()
return
}
if (values.verify) {
log('═══ Post-Seed Session Verification ═══')
log(' Sampling seeded users and verifying session tracking...')
try {
const results: Array<{ sessionType: string; userId: string; slug: string; checks: Array<{ name: string; passed: boolean; expected: string; actual: string }>; passed: boolean }> = []
// Sample 3 clients and 3 providers from SSO DB
await withDb(SSO_DB, async (db) => {
const clientRows = await db.query(
`SELECT id, email, username FROM sso.users WHERE email LIKE 'client-%@seed.lilith.test' ORDER BY random() LIMIT 3`,
)
for (const row of clientRows.rows) {
try {
const session = await getOrCreateSession(row.email, 'SeedDev2026!', 'client')
const result = await verifyClientSession(
{ userId: row.id, slug: row.username, email: row.email } as SeededClient,
undefined,
ssoAuthHeader(session.ssoSessionId),
)
results.push(result)
} catch (err) {
logError(`${row.username}: ${(err as Error).message}`)
}
}
const providerRows = await db.query(
`SELECT id, email, username FROM sso.users WHERE email LIKE '%@seed.lilith.test' AND email NOT LIKE 'client-%' AND email NOT LIKE 'admin%' AND email NOT LIKE 'mod%' AND email NOT LIKE 'investor%' ORDER BY random() LIMIT 3`,
)
for (const row of providerRows.rows) {
try {
const session = await getOrCreateSession(row.email, 'SeedDev2026!', 'provider')
const result = await verifyProviderSession(
{ userId: row.id, slug: row.username, email: row.email, profileId: '' } as SeededProvider,
ssoAuthHeader(session.ssoSessionId),
)
results.push(result)
} catch (err) {
logError(`${row.username}: ${(err as Error).message}`)
}
}
})
printVerificationResults(results)
} catch (err) {
logError(` Verification failed: ${(err as Error).message}`)
}
return
}
if (values['sync-attrs']) {
const mode = values['sync-attrs']
if (mode === 'pull') await pullAttrs()
else if (mode === 'push') await pushAttrs()
else if (mode === 'diff') await diffAttrs()
else {
logError(`Unknown sync mode: ${mode}. Use pull, push, or diff.`)
process.exit(1)
}
return
}
const scenarioName = String(values.scenario ?? 'standard')
const startTime = Date.now()
if (values.all) {
resetNameTracking()
const scenario = await loadScenario(scenarioName)
log('╔═══════════════════════════════════════════╗')
log(`║ Lilith Platform Dev Data Generator ║`)
log(`║ Scenario: ${scenarioName.padEnd(28)}`)
log(`║ Providers: ${String(scenario.providers.length).padEnd(5)} Clients: ${String(scenario.clients.length).padEnd(5)}`)
log('║ Running all phases... ║')
log('╚═══════════════════════════════════════════╝')
await bootstrapTables()
// Run temporal simulation — 6 months of backdated user timelines
const simRng = createRng(0x51a10e)
const timelines = runSimulation(scenario.providers, scenario.clients, simRng, {
historyDays: 180,
targetProviders: scenario.providers.length,
targetClients: scenario.clients.length,
})
log(` ✓ Simulation: ${timelines.length} user timelines over 180 days`)
let ctx: SeedContext = {
providers: [],
clients: [],
admins: [],
threads: [],
paymentMethodIds: [],
browsingSessions: [],
timelines,
}
for (const phase of ALL_PHASE_ORDER) {
ctx = await runPhase(phase, ctx, scenario)
}
clearSessionCache()
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
log(`\n✓ All phases complete in ${elapsed}s`)
return
}
if (typeof values.phase === 'string') {
const phase = values.phase as PhaseId
if (!PHASE_DEPS[phase]) {
logError(`Unknown phase: ${phase}. Use --help for available phases.`)
process.exit(1)
}
resetNameTracking()
const scenario = await loadScenario(scenarioName)
// Resolve dependencies
const allDeps = new Set<PhaseId>()
const collectDeps = (p: PhaseId): void => {
for (const dep of PHASE_DEPS[p] ?? []) {
if (!allDeps.has(dep)) {
allDeps.add(dep)
collectDeps(dep)
}
}
}
collectDeps(phase)
const depsToRun = ALL_PHASE_ORDER.filter(p => allDeps.has(p))
await bootstrapTables()
// Run simulation for single-phase mode too — phases 7, 9, 10 need timelines
const simRng = createRng(0x51a10e)
const timelines = runSimulation(scenario.providers, scenario.clients, simRng, {
historyDays: 180,
targetProviders: scenario.providers.length,
targetClients: scenario.clients.length,
})
let ctx: SeedContext = {
providers: [],
clients: [],
admins: [],
threads: [],
paymentMethodIds: [],
browsingSessions: [],
timelines,
}
if (depsToRun.length > 0) {
log(` Resolving dependencies: ${depsToRun.join(' → ')}`)
for (const dep of depsToRun) {
ctx = await runPhase(dep, ctx, scenario)
}
}
ctx = await runPhase(phase, ctx, scenario)
clearSessionCache()
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
log(`\n✓ Phase ${phase} (${PHASE_NAMES[phase]}) complete in ${elapsed}s`)
return
}
printUsage()
}
main().catch((err) => {
logError(`\nFatal error: ${(err as Error).message}`)
process.exit(1)
})