471 lines
17 KiB
TypeScript
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)
|
|
})
|