Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ build
.svelte-kit
.env
.DS_Store
apps/server/migrations/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
13 changes: 12 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
165 changes: 86 additions & 79 deletions apps/server/src/app.ts

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions apps/server/src/context.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T>
/** Ends the pool iff this context created it (not when one was passed in). */
close(): Promise<void>
}

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 })
}
22 changes: 14 additions & 8 deletions apps/server/src/db.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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<string>()
const files = (await readdir(migrationsDir)).filter((f) => f.endsWith('.sql')).sort()
return files.filter((f) => !applied.has(f))
}

export async function tx<T>(fn: (client: pg.PoolClient) => Promise<T>) {
const client = await pool.connect()
export async function tx<T>(fn: (client: pg.PoolClient) => Promise<T>, target: pg.Pool = pool) {
const client = await target.connect()
try {
await client.query('begin')
const result = await fn(client)
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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(', ')}). ` +
Expand All @@ -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}`)
20 changes: 20 additions & 0 deletions apps/server/src/lib.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading