diff --git a/.gitignore b/.gitignore index 446f44f..0536282 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build .svelte-kit .env .DS_Store +apps/server/migrations/ diff --git a/README.md b/README.md index 3a0ee7e..a420d63 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ See [docs/production.md](docs/production.md) for the full lifecycle, replay cont pnpm test ``` -The suite (84 TypeScript tests + 9 Python integration tests, run in CI on every push) uses the same Postgres in an isolated `tidebase_test` database. It is invariant-driven rather than coverage-driven: every test asserts a durability or safety guarantee through the public API or SDK, against real Postgres, including concurrency probes for the guarantees that only matter under contention. +The suite (93 TypeScript tests — 85 server + 8 SDK — and 12 Python integration tests, run in CI on every push) uses the same Postgres in an isolated `tidebase_test` database. It is invariant-driven rather than coverage-driven: every test asserts a durability or safety guarantee through the public API or SDK, against real Postgres, including concurrency probes for the guarantees that only matter under contention. What it proves: diff --git a/apps/server/package.json b/apps/server/package.json index 6f3a051..8f8b56c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -4,11 +4,22 @@ "version": "0.6.0", "license": "Apache-2.0", "type": "module", + "exports": { + ".": { + "types": "./dist/lib.d.ts", + "import": "./dist/lib.js" + } + }, + "files": [ + "dist", + "migrations" + ], "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "check": "tsc -p tsconfig.json --noEmit", - "test": "vitest run" + "test": "vitest run", + "prepack": "pnpm build && rm -rf migrations && cp -R ../../migrations migrations" }, "dependencies": { "@hono/node-server": "^1.13.8", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6bfc94a..add836b 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -3,13 +3,10 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' import { streamSSE } from 'hono/streaming' import { z } from 'zod' -import { pool, tx } from './db.js' import { appendEvent, listEvents, subscribe } from './events.js' +import { defaultContext, type ServerContext } from './context.js' import { nextFire, parseCron } from './cron.js' -const leaseMs = Number(process.env.TIDEBASE_LEASE_MS ?? 60_000) -const webhookSecret = process.env.TIDEBASE_WEBHOOK_SECRET -const publicUrl = (process.env.TIDEBASE_PUBLIC_URL ?? `http://localhost:${process.env.PORT ?? 7373}`).replace(/\/$/, '') function timingSafeEqualString(a: string, b: string) { const aBuf = Buffer.from(a) @@ -175,8 +172,17 @@ export type CreateAppOptions = { apiKey?: string } -export function createApp(options: CreateAppOptions = {}) { - const apiKey = options.apiKey ?? process.env.TIDEBASE_API_KEY +// Back-compat shape: createApp()/createApp({ apiKey }) builds an env-derived +// context over the shared default pool, preserving pre-context semantics +// (an explicit apiKey wins; an undefined one falls back to TIDEBASE_API_KEY). +function optionsContext(options: CreateAppOptions): ServerContext { + const ctx = defaultContext() + return options.apiKey === undefined ? ctx : { ...ctx, apiKey: options.apiKey } +} + +export function createApp(ctxOrOptions: ServerContext | CreateAppOptions = {}) { + const ctx = 'pool' in ctxOrOptions ? ctxOrOptions : optionsContext(ctxOrOptions) + const apiKey = ctx.apiKey const app = new Hono() app.use('*', cors()) app.onError((error, c) => { @@ -208,7 +214,7 @@ export function createApp(options: CreateAppOptions = {}) { app.post('/runs/:workflowName', async (c) => { const workflowName = c.req.param('workflowName') const body = createRunSchema.parse(await c.req.json()) - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const runResult = await client.query( `insert into runs (workflow_name, input_json, metadata_json, recovery_webhook) values ($1, $2, $3, $4) @@ -248,7 +254,7 @@ export function createApp(options: CreateAppOptions = {}) { }) app.get('/runs', async (c) => { - const result = await pool.query( + const result = await ctx.pool.query( 'select * from runs order by created_at desc limit 100' ) return c.json({ runs: result.rows.map(mapRun) }) @@ -262,7 +268,7 @@ export function createApp(options: CreateAppOptions = {}) { app.post('/runs/:runId/cancel', async (c) => { const runId = c.req.param('runId') const body = cancelRunSchema.parse(await c.req.json().catch(() => ({}))) - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const existing = await client.query('select * from runs where id = $1 for update', [runId]) const row = existing.rows[0] if (!row) return null @@ -297,7 +303,7 @@ export function createApp(options: CreateAppOptions = {}) { if ('terminal' in result) { return c.json({ error: 'run already completed', run: result.run }, 409) } - await dispatchChannelDeliveries(result.deliveries) + await dispatchChannelDeliveries(ctx, result.deliveries) return c.json({ run: result.run }) }) @@ -313,7 +319,7 @@ export function createApp(options: CreateAppOptions = {}) { : new Date(Date.now() + (body.delayMs ?? 0)) const deadlineAt = body.deadlineMs ? new Date(Date.now() + body.deadlineMs) : null try { - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const inserted = await client.query( `insert into runs (workflow_name, input_json, metadata_json, recovery_webhook, status, queue_name, dedupe_key, priority, run_at, max_attempts, deadline_at) @@ -351,7 +357,7 @@ export function createApp(options: CreateAppOptions = {}) { return c.json({ run: result, deduplicated: false }) } catch (error) { if ((error as { code?: string }).code === '23505' && body.dedupeKey) { - const existing = await pool.query( + const existing = await ctx.pool.query( `select * from runs where queue_name = $1 and dedupe_key = $2 and status in ('queued', 'running') limit 1`, @@ -374,7 +380,7 @@ export function createApp(options: CreateAppOptions = {}) { const leaseOwner = body.leaseOwner ?? randomUUID() const limit = body.limit ?? 1 const claimed: ReturnType[] = [] - await tx(async (client) => { + await ctx.tx(async (client) => { for (const queue of body.queues) { if (claimed.length >= limit) break // Serialize claims per queue so cap math can't race between claimers. @@ -417,7 +423,7 @@ export function createApp(options: CreateAppOptions = {}) { updated_at = now() where id = $1 returning *`, - [row.id, leaseOwner, leaseMs] + [row.id, leaseOwner, ctx.leaseMs] ) await appendEvent(client, row.id, 'run.claimed', { leaseOwner, queue }) claimed.push(mapRun(update.rows[0])) @@ -428,7 +434,7 @@ export function createApp(options: CreateAppOptions = {}) { }) app.get('/queues', async (c) => { - const stats = await pool.query( + const stats = await ctx.pool.query( `select queue_name, count(*) filter (where status = 'queued')::int as queued, count(*) filter (where status = 'running')::int as running, @@ -438,7 +444,7 @@ export function createApp(options: CreateAppOptions = {}) { from runs where queue_name is not null group by queue_name` ) - const configs = await pool.query('select * from queue_configs') + const configs = await ctx.pool.query('select * from queue_configs') const configByName = new Map(configs.rows.map((row) => [row.name, row])) const queues = stats.rows.map((row) => ({ name: row.queue_name, @@ -468,7 +474,7 @@ export function createApp(options: CreateAppOptions = {}) { app.put('/queues/:queue/config', async (c) => { const queue = c.req.param('queue') const body = queueConfigSchema.parse(await c.req.json()) - const result = await pool.query( + const result = await ctx.pool.query( `insert into queue_configs (name, concurrency, rate_per_minute, invoke_url) values ($1, $2, $3, $4) on conflict (name) do update set @@ -488,7 +494,7 @@ export function createApp(options: CreateAppOptions = {}) { const body = scheduleSchema.parse(await c.req.json()) parseCron(body.cron) // validate before persisting const next = nextFire(body.cron, new Date()) - const result = await pool.query( + const result = await ctx.pool.query( `insert into schedules (name, cron, workflow_name, input_json, queue_name, max_attempts, enabled, next_run_at) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (name) do update set @@ -510,12 +516,12 @@ export function createApp(options: CreateAppOptions = {}) { }) app.get('/schedules', async (c) => { - const result = await pool.query('select * from schedules order by name asc') + const result = await ctx.pool.query('select * from schedules order by name asc') return c.json({ schedules: result.rows.map(mapSchedule) }) }) app.delete('/schedules/:name', async (c) => { - const result = await pool.query('delete from schedules where name = $1 returning name', [ + const result = await ctx.pool.query('delete from schedules where name = $1 returning name', [ c.req.param('name') ]) if (!result.rows[0]) return c.json({ error: 'schedule not found' }, 404) @@ -525,13 +531,13 @@ export function createApp(options: CreateAppOptions = {}) { app.get('/runs/:runId', async (c) => { const runId = c.req.param('runId') const [runResult, stepsResult, stateResult, streamsResult, versionsResult, edgeResult, childRunsResult, recoveryResult, channelsResult, deliveriesResult, gatesResult, usageResult, events] = await Promise.all([ - pool.query('select * from runs where id = $1', [runId]), - pool.query('select * from steps where run_id = $1 order by created_at asc', [ + ctx.pool.query('select * from runs where id = $1', [runId]), + ctx.pool.query('select * from steps where run_id = $1 order by created_at asc', [ runId ]), - pool.query('select * from run_state where run_id = $1', [runId]), - pool.query('select * from state_streams where run_id = $1 order by created_at asc', [runId]), - pool.query( + ctx.pool.query('select * from run_state where run_id = $1', [runId]), + ctx.pool.query('select * from state_streams where run_id = $1 order by created_at asc', [runId]), + ctx.pool.query( `select v.* from state_versions v join state_streams s on s.id = v.stream_id @@ -539,8 +545,8 @@ export function createApp(options: CreateAppOptions = {}) { order by s.name asc, v.version asc`, [runId] ), - pool.query('select * from run_edges where parent_run_id = $1 order by created_at asc', [runId]), - pool.query( + ctx.pool.query('select * from run_edges where parent_run_id = $1 order by created_at asc', [runId]), + ctx.pool.query( `select r.* from runs r join run_edges e on e.child_run_id = r.id @@ -548,18 +554,18 @@ export function createApp(options: CreateAppOptions = {}) { order by e.created_at asc`, [runId] ), - pool.query( + ctx.pool.query( 'select * from recovery_attempts where run_id = $1 order by created_at desc', [runId] ), - pool.query('select * from channels where run_id = $1 order by created_at asc', [runId]), - pool.query( + ctx.pool.query('select * from channels where run_id = $1 order by created_at asc', [runId]), + ctx.pool.query( 'select * from channel_deliveries where run_id = $1 order by created_at desc limit 100', [runId] ), - pool.query('select * from gates where run_id = $1 order by created_at asc', [runId]), - pool.query('select * from usage_records where run_id = $1 order by created_at asc', [runId]), - listEvents(runId) + ctx.pool.query('select * from gates where run_id = $1 order by created_at asc', [runId]), + ctx.pool.query('select * from usage_records where run_id = $1 order by created_at asc', [runId]), + listEvents(runId, 0, ctx.pool) ]) if (!runResult.rows[0]) return c.json({ error: 'run not found' }, 404) return c.json({ @@ -582,7 +588,7 @@ export function createApp(options: CreateAppOptions = {}) { app.post('/runs/:runId/children', async (c) => { const parentRunId = c.req.param('runId') const body = createChildRunSchema.parse(await c.req.json()) - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const parent = await client.query('select * from runs where id = $1 for update', [parentRunId]) if (!parent.rows[0]) return null @@ -674,7 +680,7 @@ export function createApp(options: CreateAppOptions = {}) { app.post('/runs/:runId/begin', async (c) => { const runId = c.req.param('runId') const leaseOwner = c.req.header('x-tidebase-worker') ?? randomUUID() - const run = await tx(async (client) => { + const run = await ctx.tx(async (client) => { const result = await client.query( `select * from runs where id = $1 for update`, [runId] @@ -700,7 +706,7 @@ export function createApp(options: CreateAppOptions = {}) { updated_at = now() where id = $1 returning *`, - [runId, leaseOwner, leaseMs] + [runId, leaseOwner, ctx.leaseMs] ) await appendEvent(client, runId, 'run.started', { leaseOwner }) return mapRun(update.rows[0]) @@ -721,14 +727,14 @@ export function createApp(options: CreateAppOptions = {}) { // Extend-only renewal: no attempt bump, no run.started event. A worker // that lost its lease (reconciler reclaim, takeover, cancellation) must // not be able to resurrect it here — it learns it is a zombie instead. - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const update = await client.query( `update runs set lease_expires_at = now() + ($3 || ' milliseconds')::interval, updated_at = now() where id = $1 and status = 'running' and lease_owner = $2 returning *`, - [runId, leaseOwner, leaseMs] + [runId, leaseOwner, ctx.leaseMs] ) if (update.rows[0]) return { run: mapRun(update.rows[0]) } const existing = await client.query('select * from runs where id = $1', [runId]) @@ -746,7 +752,7 @@ export function createApp(options: CreateAppOptions = {}) { app.post('/runs/:runId/complete', async (c) => { const runId = c.req.param('runId') const body = await c.req.json() - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { // Cancellation is one-way: a worker finishing after cancel cannot // resurrect the run. const update = await client.query( @@ -776,14 +782,14 @@ export function createApp(options: CreateAppOptions = {}) { return { run: mapRun(update.rows[0]), deliveries } }) if (!result) return c.json({ error: 'run not found' }, 404) - await dispatchChannelDeliveries(result.deliveries) + await dispatchChannelDeliveries(ctx, result.deliveries) return c.json({ run: result.run }) }) app.post('/runs/:runId/fail', async (c) => { const runId = c.req.param('runId') const body = await c.req.json() - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const existing = await client.query('select * from runs where id = $1 for update', [runId]) const row = existing.rows[0] if (!row) return null @@ -840,15 +846,15 @@ export function createApp(options: CreateAppOptions = {}) { return { run: mapRun(update.rows[0]), deliveries, requeued: false, skipRecovery: false } }) if (!result) return c.json({ error: 'run not found' }, 404) - await dispatchChannelDeliveries(result.deliveries) - if (!result.skipRecovery) await dispatchRecovery(result.run, 'run_failed') + await dispatchChannelDeliveries(ctx, result.deliveries) + if (!result.skipRecovery) await dispatchRecovery(ctx, result.run, 'run_failed') return c.json({ run: result.run, requeued: result.requeued }) }) app.post('/runs/:runId/gates/begin', async (c) => { const runId = c.req.param('runId') const body = gateBeginSchema.parse(await c.req.json()) - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const runRow = await client.query('select status from runs where id = $1', [runId]) if (runRow.rows[0]?.status === 'cancelled') { return { cancelled: true as const } @@ -882,13 +888,13 @@ export function createApp(options: CreateAppOptions = {}) { ) const gate = mapGate(inserted.rows[0]) await appendEvent(client, runId, 'gate.created', { - gate: publicGate(gate) + gate: publicGate(gate, ctx.publicUrl) }) const deliveries = await queueChannelDeliveries( client, runId, 'gate.created', - { gate: publicGate(gate) }, + { gate: publicGate(gate, ctx.publicUrl) }, body.channels ?? [], gate.id ) @@ -897,12 +903,12 @@ export function createApp(options: CreateAppOptions = {}) { if ('cancelled' in result) { return c.json({ error: 'run is cancelled', code: 'run_cancelled' }, 409) } - await dispatchChannelDeliveries(result.deliveries) + await dispatchChannelDeliveries(ctx, result.deliveries) return c.json({ action: result.gate.status === 'pending' ? 'wait' : 'return', gate: result.gate }) }) app.get('/runs/:runId/gates/:gateId', async (c) => { - const result = await pool.query( + const result = await ctx.pool.query( `select g.*, r.status as run_status from gates g join runs r on r.id = g.run_id where g.run_id = $1 and g.id = $2`, @@ -916,7 +922,7 @@ export function createApp(options: CreateAppOptions = {}) { const runId = c.req.param('runId') const gateId = c.req.param('gateId') const body = gateResolveSchema.parse(await c.req.json()) - const gate = await tx(async (client) => { + const gate = await ctx.tx(async (client) => { const update = await client.query( `update gates set status = $4, @@ -939,30 +945,30 @@ export function createApp(options: CreateAppOptions = {}) { if (!update.rows[0]) return null const resolved = mapGate(update.rows[0]) await appendEvent(client, runId, 'gate.resolved', { - gate: publicGate(resolved) + gate: publicGate(resolved, ctx.publicUrl) }) const deliveries = await queueChannelDeliveries( client, runId, 'gate.resolved', - { gate: publicGate(resolved) }, + { gate: publicGate(resolved, ctx.publicUrl) }, resolved.channels, gateId ) return { gate: resolved, deliveries } }) if (!gate) return c.json({ error: 'gate not found, already resolved, or invalid token' }, 409) - await dispatchChannelDeliveries(gate.deliveries) + await dispatchChannelDeliveries(ctx, gate.deliveries) return c.json({ gate: gate.gate }) }) app.post('/runs/:runId/recover', async (c) => { const runId = c.req.param('runId') const body = await c.req.json().catch(() => ({})) - const result = await pool.query('select * from runs where id = $1', [runId]) + const result = await ctx.pool.query('select * from runs where id = $1', [runId]) const row = result.rows[0] if (!row) return c.json({ error: 'run not found' }, 404) - const attempt = await dispatchRecovery(mapRun(row), body.reason ?? 'manual') + const attempt = await dispatchRecovery(ctx, mapRun(row), body.reason ?? 'manual') if (!attempt) { return c.json({ error: 'run has no recoveryWebhook configured' }, 422) } @@ -974,7 +980,7 @@ export function createApp(options: CreateAppOptions = {}) { const body = beginStepSchema.parse(await c.req.json()) const leaseOwner = body.leaseOwner ?? randomUUID() - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { // Cancellation is enforced at step boundaries: an in-flight worker // discovers it here and unwinds instead of starting new work. const runRow = await client.query('select status from runs where id = $1', [runId]) @@ -1037,7 +1043,7 @@ export function createApp(options: CreateAppOptions = {}) { json(body.input ?? {}), json(body.options ?? {}), leaseOwner, - leaseMs + ctx.leaseMs ] ) await appendEvent(client, runId, 'step.started', { @@ -1056,7 +1062,7 @@ export function createApp(options: CreateAppOptions = {}) { const runId = c.req.param('runId') const stepId = c.req.param('stepId') const body = completeStepSchema.parse(await c.req.json()) - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const update = await client.query( `update steps set status = 'completed', @@ -1087,7 +1093,7 @@ export function createApp(options: CreateAppOptions = {}) { const runId = c.req.param('runId') const stepId = c.req.param('stepId') const body = failStepSchema.parse(await c.req.json()) - const result = await tx(async (client) => { + const result = await ctx.tx(async (client) => { const existing = await client.query( `select options_json from steps where id = $1 and run_id = $2`, [stepId, runId] @@ -1129,14 +1135,14 @@ export function createApp(options: CreateAppOptions = {}) { return { step: mapStep(update.rows[0]), deliveries } }) if (!result) return c.json({ error: 'step not found or lease lost' }, 409) - await dispatchChannelDeliveries(result.deliveries) + await dispatchChannelDeliveries(ctx, result.deliveries) return c.json({ step: result.step }) }) app.put('/runs/:runId/state', async (c) => { const runId = c.req.param('runId') const body = stateSchema.parse(await c.req.json()) - const state = await tx(async (client) => { + const state = await ctx.tx(async (client) => { const result = await client.query( `insert into run_state (run_id, value_json, version, updated_at) values ($1, $2, 1, now()) @@ -1169,14 +1175,14 @@ export function createApp(options: CreateAppOptions = {}) { }) return { state: mapState(result.rows[0]), deliveries } }) - await dispatchChannelDeliveries(state.deliveries) + await dispatchChannelDeliveries(ctx, state.deliveries) return c.json({ state: state.state }) }) app.patch('/runs/:runId/state', async (c) => { const runId = c.req.param('runId') const body = stateSchema.parse(await c.req.json()) - const state = await tx(async (client) => { + const state = await ctx.tx(async (client) => { const result = await client.query( `insert into run_state (run_id, value_json, version, updated_at) values ($1, $2, 1, now()) @@ -1210,14 +1216,14 @@ export function createApp(options: CreateAppOptions = {}) { }) return { state: mapState(result.rows[0]), deliveries } }) - await dispatchChannelDeliveries(state.deliveries) + await dispatchChannelDeliveries(ctx, state.deliveries) return c.json({ state: state.state }) }) app.post('/runs/:runId/state/save', async (c) => { const runId = c.req.param('runId') const body = stateSaveSchema.parse(await c.req.json()) - const version = await tx(async (client) => { + const version = await ctx.tx(async (client) => { const streamName = body.stream ?? 'run' const current = streamName === 'run' @@ -1259,7 +1265,7 @@ export function createApp(options: CreateAppOptions = {}) { const runId = c.req.param('runId') const stream = c.req.query('stream') const labeled = c.req.query('labeled') === 'true' - const result = await pool.query( + const result = await ctx.pool.query( `select v.* from state_versions v join state_streams s on s.id = v.stream_id @@ -1276,7 +1282,7 @@ export function createApp(options: CreateAppOptions = {}) { const runId = c.req.param('runId') const body = snapshotSchema.parse(await c.req.json()) const target = body.target ?? { type: 'run', id: runId } - const version = await tx(async (client) => { + const version = await ctx.tx(async (client) => { const saved = await recordStateVersion(client, runId, { streamName: `${target.type}:${target.id}`, targetType: target.type, @@ -1299,7 +1305,7 @@ export function createApp(options: CreateAppOptions = {}) { app.get('/runs/:runId/snapshots', async (c) => { const runId = c.req.param('runId') - const result = await pool.query( + const result = await ctx.pool.query( `select v.* from state_versions v join state_streams s on s.id = v.stream_id @@ -1313,7 +1319,7 @@ export function createApp(options: CreateAppOptions = {}) { app.post('/runs/:runId/usage', async (c) => { const runId = c.req.param('runId') const body = usageSchema.parse(await c.req.json()) - const usage = await tx(async (client) => { + const usage = await ctx.tx(async (client) => { const result = await client.query( `insert into usage_records (run_id, step_id, kind, provider, model, label, quantity, unit, input_tokens, output_tokens, total_tokens, cost_usd, metadata_json) @@ -1343,7 +1349,7 @@ export function createApp(options: CreateAppOptions = {}) { }) return { usage: record, deliveries } }) - await dispatchChannelDeliveries(usage.deliveries) + await dispatchChannelDeliveries(ctx, usage.deliveries) return c.json({ usage: usage.usage }) }) @@ -1351,7 +1357,7 @@ export function createApp(options: CreateAppOptions = {}) { const runId = c.req.param('runId') const after = Number(c.req.query('after') ?? 0) return streamSSE(c, async (stream) => { - for (const event of await listEvents(runId, after)) { + for (const event of await listEvents(runId, after, ctx.pool)) { await stream.writeSSE({ id: String(event.seq), data: JSON.stringify(event) @@ -1376,6 +1382,7 @@ export function createApp(options: CreateAppOptions = {}) { } export async function dispatchRecovery( + ctx: ServerContext, run: ReturnType, reason: string, payloadType: 'run.resume' | 'run.invoke' = 'run.resume', @@ -1391,11 +1398,11 @@ export async function dispatchRecovery( attempt: run.attempt } const body = JSON.stringify(payload) - const signature = webhookSecret - ? createHmac('sha256', webhookSecret).update(body).digest('hex') + const signature = ctx.webhookSecret + ? createHmac('sha256', ctx.webhookSecret).update(body).digest('hex') : undefined - const created = await tx(async (client) => { + const created = await ctx.tx(async (client) => { const result = await client.query( `insert into recovery_attempts (run_id, reason, webhook_url, status) values ($1, $2, $3, 'pending') @@ -1420,7 +1427,7 @@ export async function dispatchRecovery( body }) const responseBody = await response.text() - return await tx(async (client) => { + return await ctx.tx(async (client) => { const result = await client.query( `update recovery_attempts set status = $2, @@ -1443,7 +1450,7 @@ export async function dispatchRecovery( return mapRecoveryAttempt(result.rows[0]) }) } catch (error) { - return await tx(async (client) => { + return await ctx.tx(async (client) => { const result = await client.query( `update recovery_attempts set status = 'failed', @@ -1632,7 +1639,7 @@ async function queueChannelDeliveries( return queued } -async function dispatchChannelDeliveries(deliveries: QueuedChannelDelivery[]) { +async function dispatchChannelDeliveries(ctx: ServerContext, deliveries: QueuedChannelDelivery[]) { for (const delivery of deliveries) { try { const body = JSON.stringify({ @@ -1654,7 +1661,7 @@ async function dispatchChannelDeliveries(deliveries: QueuedChannelDelivery[]) { body }) const responseBody = await response.text() - await pool.query( + await ctx.pool.query( `update channel_deliveries set status = $2, http_status = $3, @@ -1664,7 +1671,7 @@ async function dispatchChannelDeliveries(deliveries: QueuedChannelDelivery[]) { [delivery.deliveryId, response.ok ? 'delivered' : 'failed', response.status, responseBody.slice(0, 8000)] ) } catch (error) { - await pool.query( + await ctx.pool.query( `update channel_deliveries set status = 'failed', error_text = $2, @@ -1696,7 +1703,7 @@ function channelMatchesEvent( return channel.events.length === 0 || channel.events.includes(eventType) } -function publicGate(gate: ReturnType) { +function publicGate(gate: ReturnType, publicUrl: string) { return { id: gate.id, runId: gate.runId, diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts new file mode 100644 index 0000000..f90fa2a --- /dev/null +++ b/apps/server/src/context.ts @@ -0,0 +1,80 @@ +// A ServerContext carries everything that varies per Tidebase instance: the +// Postgres pool and the config that used to live in module-level env reads +// (API key, webhook secret, public URL, lease window). The default boot path +// (index.ts) builds one from the environment, so self-hosted behavior is +// unchanged. Embedders can create many contexts in one process — one per +// tenant database — and pass each to createApp()/reconcileTick(); every +// advisory lock Tidebase takes is scoped to the context's database, so +// instances never interfere. +import pg from 'pg' +import { pool as defaultPool } from './db.js' + +export type ServerContext = { + pool: pg.Pool + apiKey?: string + webhookSecret?: string + publicUrl: string + leaseMs: number + tx(fn: (client: pg.PoolClient) => Promise): Promise + /** Ends the pool iff this context created it (not when one was passed in). */ + close(): Promise +} + +export type CreateContextOptions = { + /** Use an existing pool; close() will not end it. */ + pool?: pg.Pool + /** Connection string for a context-owned pool. Defaults to DATABASE_URL. */ + connectionString?: string + /** Max connections for a context-owned pool. */ + maxConnections?: number + apiKey?: string + webhookSecret?: string + publicUrl?: string + leaseMs?: number +} + +export function createContext(options: CreateContextOptions = {}): ServerContext { + const ownedPool = options.pool + ? null + : new pg.Pool({ + connectionString: + options.connectionString ?? + process.env.DATABASE_URL ?? + 'postgres://tidebase:tidebase@localhost:7432/tidebase', + ...(options.maxConnections ? { max: options.maxConnections } : {}) + }) + const pool = options.pool ?? ownedPool! + return { + pool, + apiKey: options.apiKey ?? process.env.TIDEBASE_API_KEY, + webhookSecret: options.webhookSecret ?? process.env.TIDEBASE_WEBHOOK_SECRET, + publicUrl: ( + options.publicUrl ?? + process.env.TIDEBASE_PUBLIC_URL ?? + `http://localhost:${process.env.PORT ?? 7373}` + ).replace(/\/$/, ''), + leaseMs: options.leaseMs ?? Number(process.env.TIDEBASE_LEASE_MS ?? 60_000), + async tx(fn) { + const client = await pool.connect() + try { + await client.query('begin') + const result = await fn(client) + await client.query('commit') + return result + } catch (error) { + await client.query('rollback') + throw error + } finally { + client.release() + } + }, + async close() { + if (ownedPool) await ownedPool.end() + } + } +} + +/** Env-configured context over the shared default pool (db.ts). */ +export function defaultContext(): ServerContext { + return createContext({ pool: defaultPool }) +} diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index e595c47..8d77f6a 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { readdir, readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,7 +11,12 @@ export const pool = new pg.Pool({ }) const __dirname = dirname(fileURLToPath(import.meta.url)) -const migrationsDir = resolve(__dirname, '../../../migrations') +// Monorepo layout keeps migrations at the repo root; the published package +// ships its own copy (see prepack) one level above src/dist. +const migrationsDir = [ + resolve(__dirname, '../../../migrations'), + resolve(__dirname, '../migrations') +].find(existsSync) ?? resolve(__dirname, '../../../migrations') // Versioned migration runner. Files in migrations/ are applied in // lexicographic order, exactly once, recorded in schema_migrations, under an @@ -19,8 +25,8 @@ const migrationsDir = resolve(__dirname, '../../../migrations') // fail fast when migrations are pending (expand/contract deploy discipline). const MIGRATION_LOCK = 0x74646201 // 'tdb' 01 -export async function migrate() { - const client = await pool.connect() +export async function migrate(target: pg.Pool = pool) { + const client = await target.connect() try { await client.query('select pg_advisory_lock($1)', [MIGRATION_LOCK]) await client.query( @@ -53,21 +59,21 @@ export async function migrate() { } } -export async function pendingMigrations() { - const exists = await pool.query( +export async function pendingMigrations(target: pg.Pool = pool) { + const exists = await target.query( `select 1 from information_schema.tables where table_name = 'schema_migrations'` ) const applied = exists.rows[0] ? new Set( - (await pool.query('select name from schema_migrations')).rows.map((r) => r.name) + (await target.query('select name from schema_migrations')).rows.map((r) => r.name) ) : new Set() const files = (await readdir(migrationsDir)).filter((f) => f.endsWith('.sql')).sort() return files.filter((f) => !applied.has(f)) } -export async function tx(fn: (client: pg.PoolClient) => Promise) { - const client = await pool.connect() +export async function tx(fn: (client: pg.PoolClient) => Promise, target: pg.Pool = pool) { + const client = await target.connect() try { await client.query('begin') const result = await fn(client) diff --git a/apps/server/src/events.ts b/apps/server/src/events.ts index 809e088..95a8f3e 100644 --- a/apps/server/src/events.ts +++ b/apps/server/src/events.ts @@ -58,8 +58,8 @@ export async function appendEvent( return event } -export async function listEvents(runId: string, afterSeq = 0) { - const result = await pool.query( +export async function listEvents(runId: string, afterSeq = 0, target: pg.Pool = pool) { + const result = await target.query( `select id, run_id, seq, type, payload_json, created_at from events where run_id = $1 and seq > $2 diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index aeda127..fa2c2eb 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,15 +1,18 @@ import { serve } from '@hono/node-server' import { createApp } from './app.js' +import { defaultContext } from './context.js' import { migrate, pendingMigrations } from './db.js' import { startReconciler } from './reconciler.js' const port = Number(process.env.PORT ?? 7373) const autoMigrate = process.env.TIDEBASE_AUTO_MIGRATE !== '0' +const ctx = defaultContext() + if (autoMigrate) { - await migrate() + await migrate(ctx.pool) } else { - const pending = await pendingMigrations() + const pending = await pendingMigrations(ctx.pool) if (pending.length > 0) { console.error( `refusing to start: ${pending.length} pending migration(s) (${pending.join(', ')}). ` + @@ -20,10 +23,10 @@ if (autoMigrate) { } serve({ - fetch: createApp().fetch, + fetch: createApp(ctx).fetch, port }) -startReconciler() +startReconciler(ctx) console.log(`Tidebase server listening on http://localhost:${port}`) diff --git a/apps/server/src/lib.ts b/apps/server/src/lib.ts new file mode 100644 index 0000000..f7adb6a --- /dev/null +++ b/apps/server/src/lib.ts @@ -0,0 +1,20 @@ +// Library entry point: embed the Tidebase server in your own Node process. +// +// import { createContext, createApp, reconcileTick, migrate } from '@tidebase/server' +// +// const ctx = createContext({ connectionString }) +// await migrate(ctx.pool) +// serve({ fetch: createApp(ctx).fetch, port }) +// setInterval(() => reconcileTick(new Date(), ctx), 5000) +// +// A context is one Tidebase instance over one Postgres database. Create many +// contexts in one process to serve many isolated databases (e.g. one per +// tenant); advisory locks, the reconciler, and migrations are all scoped to +// the context's database. +export { createContext, defaultContext } from './context.js' +export type { CreateContextOptions, ServerContext } from './context.js' +export { createApp } from './app.js' +export type { CreateAppOptions } from './app.js' +export { reconcileTick, startReconciler } from './reconciler.js' +export type { TickReport } from './reconciler.js' +export { migrate, pendingMigrations } from './db.js' diff --git a/apps/server/src/reconciler.ts b/apps/server/src/reconciler.ts index d02dd2c..a39497b 100644 --- a/apps/server/src/reconciler.ts +++ b/apps/server/src/reconciler.ts @@ -9,7 +9,7 @@ // invokeUrl as signed run.invoke webhooks // Tidebase still never executes user code: every path here either updates // lifecycle state in Postgres or fires a signed HTTP invocation at YOUR app. -import { pool, tx } from './db.js' +import { defaultContext, type ServerContext } from './context.js' import { appendEvent } from './events.js' import { dispatchRecovery, mapRun, retryBackoffMs } from './app.js' import { nextFire } from './cron.js' @@ -26,8 +26,11 @@ export type TickReport = { invoked: number } -export async function reconcileTick(now = new Date()): Promise { - const lockClient = await pool.connect() +export async function reconcileTick( + now = new Date(), + ctx: ServerContext = defaultContext() +): Promise { + const lockClient = await ctx.pool.connect() const report: TickReport = { requeued: 0, failed: 0, @@ -40,10 +43,10 @@ export async function reconcileTick(now = new Date()): Promise undefined) @@ -51,12 +54,12 @@ export async function reconcileTick(now = new Date()): Promise { + await ctx.tx(async (client) => { const update = await client.query( `update runs set status = 'queued', lease_owner = null, lease_expires_at = null, @@ -86,7 +89,7 @@ async function sweepExpiredLeases(report: TickReport, now: Date) { } }) } else { - await tx(async (client) => { + await ctx.tx(async (client) => { const update = await client.query( `update runs set status = 'failed', failure_class = 'max_retries', @@ -107,29 +110,29 @@ async function sweepExpiredLeases(report: TickReport, now: Date) { } else if (row.recovery_webhook) { // Non-queue stalled run: nudge the owning app over its recovery // webhook, throttled to one attempt per lease window. - const recent = await pool.query( + const recent = await ctx.pool.query( `select 1 from recovery_attempts where run_id = $1 and created_at > now() - interval '60 seconds' limit 1`, [row.id] ) if (!recent.rows[0]) { - await dispatchRecovery(mapRun(row), 'lease_expired') + await dispatchRecovery(ctx, mapRun(row), 'lease_expired') report.recovered += 1 } } } } -async function sweepDeadlines(report: TickReport, now: Date) { - const overdue = await pool.query( +async function sweepDeadlines(ctx: ServerContext, report: TickReport, now: Date) { + const overdue = await ctx.pool.query( `select id from runs where deadline_at is not null and deadline_at < $1 and status in ('pending', 'queued', 'running') limit 100`, [now] ) for (const row of overdue.rows) { - await tx(async (client) => { + await ctx.tx(async (client) => { const update = await client.query( `update runs set status = 'cancelled', cancelled_at = now(), cancel_requested_at = now(), @@ -147,8 +150,8 @@ async function sweepDeadlines(report: TickReport, now: Date) { } } -async function sweepSchedules(report: TickReport, now: Date) { - const due = await pool.query( +async function sweepSchedules(ctx: ServerContext, report: TickReport, now: Date) { + const due = await ctx.pool.query( `select * from schedules where enabled and next_run_at is not null and next_run_at <= $1`, [now] ) @@ -157,7 +160,7 @@ async function sweepSchedules(report: TickReport, now: Date) { // The dedupe key carries the fire time: even if two replicas raced past // the advisory lock, only one enqueue can win. try { - await tx(async (client) => { + await ctx.tx(async (client) => { await client.query( `insert into runs (workflow_name, input_json, status, queue_name, dedupe_key, run_at, max_attempts, metadata_json) values ($1, $2, 'queued', $3, $4, now(), $5, $6)`, @@ -175,26 +178,26 @@ async function sweepSchedules(report: TickReport, now: Date) { } catch (error) { if ((error as { code?: string }).code !== '23505') throw error // dedupe hit = already fired } - await pool.query( + await ctx.pool.query( `update schedules set last_enqueued_at = now(), next_run_at = $2, updated_at = now() where name = $1`, [schedule.name, nextFire(schedule.cron, now)] ) } } -async function sweepPushQueues(report: TickReport, now: Date) { - const pushQueues = await pool.query(`select * from queue_configs where invoke_url is not null`) +async function sweepPushQueues(ctx: ServerContext, report: TickReport, now: Date) { + const pushQueues = await ctx.pool.query(`select * from queue_configs where invoke_url is not null`) for (const config of pushQueues.rows) { let capacity = 10 if (config.concurrency != null) { - const running = await pool.query( + const running = await ctx.pool.query( `select count(*)::int as n from runs where queue_name = $1 and status = 'running'`, [config.name] ) capacity = Math.min(capacity, Math.max(0, config.concurrency - running.rows[0].n)) } if (capacity <= 0) continue - const ready = await pool.query( + const ready = await ctx.pool.query( `select * from runs where queue_name = $1 and status = 'queued' and run_at <= $2 order by priority desc, run_at asc @@ -205,19 +208,19 @@ async function sweepPushQueues(report: TickReport, now: Date) { // Push the redelivery horizon forward BEFORE dispatching so a slow // endpoint can't cause a duplicate dispatch on the next tick. The run // stays 'queued' until the app's webhook handler begins it. - await pool.query(`update runs set run_at = now() + interval '60 seconds' where id = $1`, [ + await ctx.pool.query(`update runs set run_at = now() + interval '60 seconds' where id = $1`, [ row.id ]) - await dispatchRecovery(mapRun(row), 'queue_dispatch', 'run.invoke', config.invoke_url) + await dispatchRecovery(ctx, mapRun(row), 'queue_dispatch', 'run.invoke', config.invoke_url) report.invoked += 1 } } } -export function startReconciler() { +export function startReconciler(ctx: ServerContext = defaultContext()) { if (process.env.TIDEBASE_RECONCILER === '0') return const timer = setInterval(() => { - reconcileTick().catch((error) => console.error('reconciler tick failed:', error)) + reconcileTick(new Date(), ctx).catch((error) => console.error('reconciler tick failed:', error)) }, tickMs) timer.unref?.() return timer diff --git a/apps/server/test/embed.test.ts b/apps/server/test/embed.test.ts new file mode 100644 index 0000000..6b0d232 --- /dev/null +++ b/apps/server/test/embed.test.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import pg from 'pg' +import { createApp, createContext, migrate, reconcileTick } from '../src/lib' +import type { ServerContext } from '../src/lib' +import { api, createRun } from './helpers' + +// Invariant: one process can host many Tidebase instances, one per database, +// via createContext() — the embedding contract the cloud gateway depends on. +// Runs in one context are invisible to the other, per-context API keys are +// independent, and a reconciler tick only acts on its own context's database +// (its advisory tick lock is database-scoped, so ticks don't serialize across +// contexts either). + +const defaultDatabaseUrl = + process.env.DATABASE_URL ?? 'postgres://tidebase:tidebase@localhost:7432/tidebase_test' +const embedUrl = new URL(defaultDatabaseUrl) +embedUrl.pathname = '/tidebase_test_embed' + +let embedCtx: ServerContext + +beforeAll(async () => { + const adminUrl = new URL(defaultDatabaseUrl) + adminUrl.pathname = '/postgres' + const admin = new pg.Client({ connectionString: adminUrl.toString() }) + await admin.connect() + const exists = await admin.query('select 1 from pg_database where datname = $1', [ + 'tidebase_test_embed' + ]) + if (exists.rows.length === 0) { + await admin.query('create database "tidebase_test_embed"') + } + await admin.end() + + embedCtx = createContext({ connectionString: embedUrl.toString() }) + await migrate(embedCtx.pool) + const tables = await embedCtx.pool.query( + `select tablename from pg_tables + where schemaname = 'public' and tablename <> 'schema_migrations'` + ) + const names = tables.rows.map((r) => `"${r.tablename}"`).join(', ') + await embedCtx.pool.query(`truncate ${names} restart identity cascade`) +}) + +afterAll(async () => { + await embedCtx.close() +}) + +describe('embedded multi-database contexts', () => { + it('isolates runs between contexts in the same process', async () => { + const defaultApp = createApp() + const embedApp = createApp(embedCtx) + + const runA = await createRun(defaultApp, 'embed-default-wf') + const runB = await createRun(embedApp, 'embed-tenant-wf') + expect(runA.id).not.toBe(runB.id) + + const crossA = await api(embedApp, 'GET', `/runs/${runA.id}`) + expect(crossA.status).toBe(404) + const crossB = await api(defaultApp, 'GET', `/runs/${runB.id}`) + expect(crossB.status).toBe(404) + + const ownB = await api(embedApp, 'GET', `/runs/${runB.id}`) + expect(ownB.status).toBe(200) + expect(ownB.body.run.workflowName).toBe('embed-tenant-wf') + }) + + it('applies per-context API keys independently', async () => { + const openApp = createApp() + const lockedApp = createApp({ ...embedCtx, apiKey: 'tenant-key' }) + + const denied = await api(lockedApp, 'GET', '/runs') + expect(denied.status).toBe(401) + const allowed = await api(lockedApp, 'GET', '/runs', undefined, { + authorization: 'Bearer tenant-key' + }) + expect(allowed.status).toBe(200) + const open = await api(openApp, 'GET', '/runs') + expect(open.status).toBe(200) + }) + + it('scopes reconciler ticks to their own context', async () => { + const embedApp = createApp(embedCtx) + + const enqueued = await api(embedApp, 'POST', '/queues/embed-q/enqueue', { + workflowName: 'embed-queued-wf', + maxAttempts: 3 + }) + expect(enqueued.status).toBe(200) + const runId = enqueued.body.run.id as string + + const claimed = await api(embedApp, 'POST', '/queues/claim', { queues: ['embed-q'] }) + expect(claimed.body.runs).toHaveLength(1) + await embedCtx.pool.query( + `update runs set lease_expires_at = now() - interval '1 second' where id = $1`, + [runId] + ) + + // The embed database has no competing tickers, so one tick is decisive — + // unlike the shared test database, where parallel files contend for the + // (database-scoped) tick lock. + const report = await reconcileTick(new Date(), embedCtx) + expect(report).not.toBeNull() + expect(report!.requeued).toBe(1) + + const after = await api(embedApp, 'GET', `/runs/${runId}`) + expect(after.body.run.status).toBe('queued') + }) +}) diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 5a24989..01c4d9a 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "declaration": true }, "include": ["src"] } diff --git a/docs/testing.md b/docs/testing.md index 7e61048..b87f373 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -50,6 +50,7 @@ The SDK unit tests (`packages/sdk/test`) need no infrastructure. | Push-mode dispatch records signed run.invoke deliveries win-or-lose and never double-dispatches within the redelivery horizon | At-least-once with a bounded duplicate window | `schedules.test.ts` | | Deadlines cancel overdue runs with reason `deadline` via the reconciler | Timeouts are durable and externally visible | `cancellation.test.ts` | | The SDK work loop executes registered workflows off claimed runs, and a gate-blocked worker unwinds with RunCancelledError on cancel | The client honors the same lifecycle the server enforces | `e2e-queues.test.ts` | +| Many embedded contexts coexist in one process: runs are invisible across contexts, API keys apply per context, and a reconciler tick acts only on its own context's database | The embedding contract multi-tenant hosting is built on — one database per tenant, zero shared state | `embed.test.ts` | ## Conventions diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 9a1ab99..7805eed 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -1,19 +1,29 @@ #!/usr/bin/env node /** - * tidebase CLI — currently one command: + * tidebase CLI: * - * tidebase init Write a Tidebase usage block into the project's - * AGENTS.md/CLAUDE.md so AI coding sessions in this repo - * use Tidebase correctly instead of hand-rolling status - * columns. Idempotent: skips files that already have a - * "## Tidebase" section. + * tidebase signup Provision a hosted Tidebase (Tidebase Cloud) and write + * TIDEBASE_URL + TIDEBASE_API_KEY into .env — no Docker, + * no Postgres. GitHub device flow by default (works for an + * AI agent: it relays a code, a human approves once), or + * `--email you@example.com` for a magic-link flow. + * + * tidebase init Write a Tidebase usage block into the project's + * AGENTS.md/CLAUDE.md so AI coding sessions in this repo + * use Tidebase correctly instead of hand-rolling status + * columns. Idempotent: skips files that already have a + * "## Tidebase" section. */ import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { resolve } from 'node:path' +const DEFAULT_CLOUD_URL = 'https://api.tidebase.dev' + const SNIPPET = `## Tidebase (checkpoint layer) -This project uses [Tidebase](https://github.com/BlueprintLabIO/tidebase) (\`@tidebase/sdk\`) to checkpoint long-running agent workflows. Server: \`$TIDEBASE_URL\` (default \`http://localhost:7373\`), Studio dashboard on \`:5173\`. +This project uses [Tidebase](https://github.com/BlueprintLabIO/tidebase) (\`@tidebase/sdk\`) to checkpoint long-running agent workflows. Point the SDK at a server with \`$TIDEBASE_URL\`. + +**No server yet?** Either provision a hosted one — \`npx @tidebase/sdk signup\` writes \`TIDEBASE_URL\` and \`TIDEBASE_API_KEY\` into \`.env\` — or self-host: \`docker compose up -d postgres && pnpm dev\` (default \`http://localhost:7373\`, Studio on \`:5173\`). **When writing multi-step or long-running workflows in this repo, wrap them in Tidebase instead of hand-rolling status columns, retry flags, or checkpoint blobs:** @@ -39,6 +49,8 @@ Rules: - Debugging a stuck/failed run: check Studio or \`GET $TIDEBASE_URL/runs/:runId\` for steps, failure classification, and pending gates. ` +// ---- init ----------------------------------------------------------------- + function inject(file: string): 'written' | 'skipped' | 'created' { const path = resolve(process.cwd(), file) if (!existsSync(path)) return 'created' @@ -68,17 +80,155 @@ function init() { } console.log(` -Next steps: - - Server + Studio: git clone https://github.com/BlueprintLabIO/tidebase && cd tidebase && docker compose up -d postgres && pnpm install && pnpm dev +Next steps — give this repo a Tidebase server (pick one): + - Hosted (no Docker): npx @tidebase/sdk signup + - Self-host: git clone https://github.com/BlueprintLabIO/tidebase && cd tidebase && docker compose up -d postgres && pnpm install && pnpm dev - Give your AI assistant run access (MCP): claude mcp add tidebase -e TIDEBASE_URL=http://localhost:7373 -- npx -y @tidebase/mcp - Docs for agents and humans: https://tidebase.dev/llms.txt`) } +// ---- signup --------------------------------------------------------------- + +/** Upsert keys into .env, preserving and not clobbering other lines. */ +export function upsertEnv(path: string, vars: Record): string { + const body = existsSync(path) ? readFileSync(path, 'utf8') : '' + const lines = body.replace(/\n+$/, '').length ? body.replace(/\n+$/, '').split('\n') : [] + for (const [key, value] of Object.entries(vars)) { + const line = `${key}=${value}` + const index = lines.findIndex((l) => new RegExp(`^\\s*${key}\\s*=`).test(l)) + if (index >= 0) lines[index] = line + else lines.push(line) + } + const next = `${lines.join('\n')}\n` + writeFileSync(path, next) + return next +} + +function flag(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +type Credentials = { + endpoint: string + apiKey: string + webhookSecret: string + slug: string +} +type PollResponse = { status: 'pending' } | { status: 'ready'; credentials: Credentials } + +async function post(url: string, body?: unknown) { + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body) + }) + } catch { + throw new CloudUnreachable() + } + const text = await response.text() + const json = text ? (JSON.parse(text) as Record) : {} + if (!response.ok) { + throw new Error((json.error as string) ?? `request failed (${response.status})`) + } + return json +} + +class CloudUnreachable extends Error {} + +async function pollUntilReady(pollUrl: string, requestId: string, intervalSec: number) { + const deadline = Date.now() + 15 * 60_000 + while (Date.now() < deadline) { + await sleep(Math.max(2, intervalSec) * 1000) + const result = (await post(pollUrl, { requestId })) as unknown as PollResponse + if (result.status === 'ready') return result.credentials + } + throw new Error('signup timed out (the link/code is valid for 15 minutes — run signup again)') +} + +function writeCredentials(credentials: Credentials) { + const path = resolve(process.cwd(), '.env') + upsertEnv(path, { + TIDEBASE_URL: credentials.endpoint, + TIDEBASE_API_KEY: credentials.apiKey, + TIDEBASE_WEBHOOK_SECRET: credentials.webhookSecret + }) + console.log(` +✓ Provisioned Tidebase Cloud for "${credentials.slug}". + Wrote TIDEBASE_URL, TIDEBASE_API_KEY, and TIDEBASE_WEBHOOK_SECRET to .env + Endpoint: ${credentials.endpoint} + +Your code can now \`new Tidebase()\` with no further config. Load .env (e.g. node --env-file=.env, or your framework's loader) and run.`) +} + +async function signup() { + const cloudUrl = (flag('cloud-url') ?? process.env.TIDEBASE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace( + /\/$/, + '' + ) + const email = flag('email') + try { + if (email) { + const start = await post(`${cloudUrl}/v1/signup/email/start`, { email }) + console.log(` +Tidebase Cloud signup (email) + + Sent a confirmation link to ${email}. + Open it in any browser, then return here — your API key is issued to this terminal. +`) + const credentials = await pollUntilReady( + `${cloudUrl}/v1/signup/email/poll`, + start.requestId as string, + (start.interval as number) ?? 5 + ) + writeCredentials(credentials) + } else { + const start = await post(`${cloudUrl}/v1/signup/github/start`) + console.log(` +Tidebase Cloud signup (GitHub) + + 1. Open: ${start.verificationUri} + 2. Enter code: ${start.userCode} + +Waiting for authorization… (an AI agent can relay the code above to its human; no browser needed here.) +`) + const credentials = await pollUntilReady( + `${cloudUrl}/v1/signup/github/poll`, + start.requestId as string, + (start.interval as number) ?? 5 + ) + writeCredentials(credentials) + } + } catch (error) { + if (error instanceof CloudUnreachable) { + console.error(` +Couldn't reach Tidebase Cloud at ${cloudUrl}. +It may not be live in your region yet. You can self-host instead: + + git clone https://github.com/BlueprintLabIO/tidebase + cd tidebase && docker compose up -d postgres && pnpm install && pnpm dev + +…then set TIDEBASE_URL=http://localhost:7373.`) + } else { + console.error(`signup failed: ${(error as Error).message}`) + } + process.exitCode = 1 + } +} + +// ---- dispatch ------------------------------------------------------------- + const command = process.argv[2] if (command === 'init') { init() +} else if (command === 'signup') { + void signup() } else { - console.log('Usage: npx @tidebase/sdk init') + console.log('Usage: npx @tidebase/sdk ') if (command) process.exitCode = 1 } diff --git a/packages/sdk/test/cli-env.test.ts b/packages/sdk/test/cli-env.test.ts new file mode 100644 index 0000000..20fcb9f --- /dev/null +++ b/packages/sdk/test/cli-env.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { upsertEnv } from '../src/cli' + +function tmpEnv(initial?: string) { + const dir = mkdtempSync(join(tmpdir(), 'tide-env-')) + const path = join(dir, '.env') + if (initial !== undefined) writeFileSync(path, initial) + return path +} + +describe('upsertEnv (tidebase signup writes credentials)', () => { + it('creates .env with the credentials when none exists', () => { + const path = tmpEnv() + upsertEnv(path, { TIDEBASE_URL: 'https://x', TIDEBASE_API_KEY: 'tbk_1' }) + expect(readFileSync(path, 'utf8')).toBe('TIDEBASE_URL=https://x\nTIDEBASE_API_KEY=tbk_1\n') + }) + + it('updates existing Tidebase keys in place without touching other vars', () => { + const path = tmpEnv('DATABASE_URL=postgres://local\nTIDEBASE_API_KEY=old\nPORT=3000\n') + upsertEnv(path, { TIDEBASE_URL: 'https://x', TIDEBASE_API_KEY: 'tbk_new' }) + const out = readFileSync(path, 'utf8') + expect(out).toContain('DATABASE_URL=postgres://local') + expect(out).toContain('PORT=3000') + expect(out).toContain('TIDEBASE_API_KEY=tbk_new') + expect(out).not.toContain('TIDEBASE_API_KEY=old') + // appended, not duplicated + expect(out.match(/TIDEBASE_API_KEY=/g)).toHaveLength(1) + expect(out.match(/TIDEBASE_URL=/g)).toHaveLength(1) + }) + + it('is idempotent — re-running with the same values is a no-op', () => { + const path = tmpEnv() + const first = upsertEnv(path, { TIDEBASE_URL: 'https://x', TIDEBASE_API_KEY: 'tbk_1' }) + const second = upsertEnv(path, { TIDEBASE_URL: 'https://x', TIDEBASE_API_KEY: 'tbk_1' }) + expect(second).toBe(first) + }) + + it('does not clobber a var whose name is a prefix of a Tidebase key', () => { + const path = tmpEnv('TIDEBASE_URL_BACKUP=keepme\n') + upsertEnv(path, { TIDEBASE_URL: 'https://x' }) + const out = readFileSync(path, 'utf8') + expect(out).toContain('TIDEBASE_URL_BACKUP=keepme') + expect(out).toContain('TIDEBASE_URL=https://x') + }) +})