Files
2026-06-21 19:23:05 +02:00

204 lines
7.0 KiB
TypeScript

// Local single-process harness for end-to-end testing the kanban workflow
// path WITHOUT Cloudflare. Hosts the kanban HTTP wirings, fakes the fabric
// `/tenant/enqueue` endpoint, and runs queue jobs in-process via the same
// `runQueueJob` the CF handler-factories use. Workflow services are wired
// the same way the generated CF entry does (SQLiteKyselyWorkflowService +
// SQLiteKyselyWorkflowRunService over libsql).
//
// Usage:
// export DATABASE_URL='libsql://...?authToken=...' # MUST be set
// npx tsx scripts/local-server.ts
//
// Then:
// curl -X POST http://127.0.0.1:9100/workflow/cardOnboardingWorkflow/start \
// -H 'content-type: application/json' \
// -d '{"data":{"title":"local-e2e","status":"todo"}}'
// # poll /workflow/cardOnboardingWorkflow/status/<runId>
import http from 'node:http'
import { randomUUID } from 'node:crypto'
import { Kysely, CamelCasePlugin } from 'kysely'
import {
LibsqlWebDialect,
SQLiteKyselyWorkflowService,
SQLiteKyselyWorkflowRunService,
type KyselyPikkuDB,
} from '@pikku/kysely-sqlite'
import { SerializePlugin } from '@pikku/kysely'
import { runQueueJob, type QueueJob, type QueueJobStatus } from '@pikku/core/queue'
import { fetchData, PikkuFetchHTTPResponse } from '@pikku/core/http'
import { compileAllSchemas } from '@pikku/core/schema'
import { incomingMessageToRequest, writeResponse } from '@pikku/node-http-server'
import { createConfig } from '../src/config.js'
import { createSingletonServices } from '../src/services.js'
import '../.pikku/pikku-bootstrap.gen.js'
const PORT = Number(process.env.PORT ?? 9100)
const HOST = process.env.HOST ?? '127.0.0.1'
const STAGE_ID = process.env.FABRIC_STAGE_ID ?? 'local-stage'
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.error(
'DATABASE_URL required (libsql URL with embedded authToken). Pull one from app.stage.database_url.',
)
process.exit(1)
}
// Mirrors the inline FabricTenantQueueService used by the deploy runtime.
// Same body shape, no HMAC since the local /tenant/enqueue endpoint accepts
// any caller.
class FabricTenantQueueServiceLocal {
supportsResults = false
constructor(
private readonly endpoint: string,
private readonly stageId: string,
) {}
async add(queueName: string, data: unknown, options?: { jobId?: string }): Promise<string> {
const body = JSON.stringify({
stageId: this.stageId,
pikkuQueueName: queueName,
payload: data,
jobId: options?.jobId,
})
const res = await fetch(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(
`local tenant-queue: enqueue ${queueName}${res.status} ${text.slice(0, 200)}`,
)
}
const json = (await res.json()) as { jobId: string }
return json.jobId
}
async getJob(): Promise<null> {
return null
}
}
function mkJob(queueName: string, data: unknown, id: string): QueueJob {
return {
queueName,
id,
data,
status: async () => 'active' as QueueJobStatus,
metadata: () => ({
processedAt: new Date(),
attemptsMade: 0,
maxAttempts: undefined,
result: undefined,
progress: 0,
createdAt: new Date(),
completedAt: undefined,
failedAt: undefined,
error: undefined,
}),
waitForCompletion: async () => {
throw new Error('local: waitForCompletion not supported')
},
}
}
async function readBody(req: http.IncomingMessage): Promise<string> {
const chunks: Buffer[] = []
for await (const c of req as AsyncIterable<Buffer>) chunks.push(c)
return Buffer.concat(chunks).toString('utf-8')
}
async function main(): Promise<void> {
const config = await createConfig()
const queueEndpoint = `http://${HOST}:${PORT}/tenant/enqueue`
const queueService = new FabricTenantQueueServiceLocal(queueEndpoint, STAGE_ID)
// Pass queueService through existingServices — the template's
// createSingletonServices preserves anything in existingServices that
// it doesn't explicitly override.
const services = (await createSingletonServices(config, {
queueService: queueService as never,
})) as Record<string, unknown>
const workflowKysely = new Kysely<KyselyPikkuDB>({
dialect: new LibsqlWebDialect({ url: DATABASE_URL! }),
plugins: [new CamelCasePlugin(), new SerializePlugin()],
})
const workflowService = new SQLiteKyselyWorkflowService(workflowKysely)
await workflowService.init()
services.workflowService = workflowService
services.workflowRunService = new SQLiteKyselyWorkflowRunService(workflowKysely)
compileAllSchemas((services as { logger: { info: (s: string) => void } }).logger as never)
const log = (services as { logger: { info: (s: string) => void; error: (s: string) => void } })
.logger
const server = http.createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/tenant/enqueue') {
const text = await readBody(req)
let body: {
stageId?: string
pikkuQueueName: string
payload: unknown
jobId?: string
}
try {
body = JSON.parse(text)
} catch {
res.writeHead(400, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: 'invalid_json' }))
return
}
const jobId = body.jobId ?? randomUUID()
log.info(`[enqueue] queue=${body.pikkuQueueName} jobId=${jobId} stage=${body.stageId}`)
// Async, fire-and-forget — mirrors pg-boss behaviour: the producer
// gets an immediate ack and the job runs on the worker side.
void (async () => {
try {
await runQueueJob({
job: mkJob(body.pikkuQueueName, body.payload, jobId),
})
log.info(`[queue:${body.pikkuQueueName}] ${jobId} done`)
} catch (e) {
const err = e as Error
log.error(
`[queue:${body.pikkuQueueName}] ${jobId} FAILED: ${err.message}\n${err.stack ?? ''}`,
)
}
})()
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ jobId }))
return
}
const request = incomingMessageToRequest(req)
const pikkuResponse = new PikkuFetchHTTPResponse()
await fetchData(request, pikkuResponse, { respondWith404: true })
await writeResponse(res, pikkuResponse.toResponse())
} catch (err) {
log.error(`local-server: ${(err as Error).message}`)
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'application/json' })
}
try {
res.end(JSON.stringify({ error: 'internal_error' }))
} catch {
// already ended
}
}
})
server.listen(PORT, HOST, () => {
log.info(`local-server: listening on http://${HOST}:${PORT}`)
log.info(`local-server: stage=${STAGE_ID} db=${DATABASE_URL!.split('?')[0]}`)
})
}
main().catch((e) => {
console.error('local-server: fatal', e)
process.exit(1)
})