Service: job event stream, async in-memory backend, preview routes

Ported from PR #15 (packages-side surface only; the fork's dev UI page is
intentionally dropped - the API surface is the product):
- job_events table + repository, worker emits stage transitions, additive
  GET /v1/clones/:id/events polling route. The fork's hand-written
  migration was never registered in Drizzle's journal (it silently never
  applied); regenerated properly via drizzle-kit from schema.ts with the
  (job_id, seq) index declared, and verified against a live Postgres
- In-memory backend moves to the documented enqueue-202 + poll contract
  with single-flight BUSY guard; POST honors backend httpStatus
- app-preview static serving routes; the preview BUILD half is deferred
  until the compiler exports buildApp/DEFAULT_HARNESS_DIR - routes 404
  cleanly until then
- preview option threaded through core types; cache key intentionally
  unchanged while the flag is inert

All workspaces typecheck; api 18/18, core 13/13, worker 1/1 pass;
migration verified applying (table + index + FK) on postgres:16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 15:00:55 -07:00
co-authored by Claude Fable 5
parent f991f469cf
commit bbe4a5643b
16 changed files with 701 additions and 55 deletions
+22 -1
View File
@@ -1,6 +1,6 @@
import { and, desc, eq, gt, isNull, sql } from "drizzle-orm";
import type { Db } from "./client.js";
import { jobs, clones, cache, apiKeys, signupTokens, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.js";
import { jobs, clones, cache, apiKeys, signupTokens, jobEvents, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.js";
// ---- jobs ----
@@ -111,3 +111,24 @@ export async function consumeSignupToken(db: Db, tokenHash: string): Promise<Sig
.returning();
return row;
}
// ---- job events ----
export async function appendJobEvent(db: Db, jobId: string, payload: Record<string, unknown>): Promise<number> {
const [row] = await db
.select({ n: sql<number>`coalesce(max(${jobEvents.seq}), 0)` })
.from(jobEvents)
.where(eq(jobEvents.jobId, jobId));
const seq = (row?.n ?? 0) + 1;
await db.insert(jobEvents).values({ jobId, seq, payload: { t: Date.now(), ...payload } });
return seq;
}
export async function listJobEvents(db: Db, jobId: string, after = 0): Promise<Array<Record<string, unknown>>> {
const rows = await db
.select()
.from(jobEvents)
.where(and(eq(jobEvents.jobId, jobId), sql`${jobEvents.seq} > ${after}`))
.orderBy(jobEvents.seq);
return rows.map((r) => r.payload as Record<string, unknown>);
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, text, jsonb, integer, timestamp, uuid } from "drizzle-orm/pg-core";
import { pgTable, text, jsonb, integer, timestamp, uuid, index } from "drizzle-orm/pg-core";
/** Job lifecycle row. Status mirrors the queue; `cacheKey` ties it to the cache row.
* `options`/`timings` are jsonb (the service's typed shapes). */
@@ -66,6 +66,17 @@ export const signupTokens = pgTable("signup_tokens", {
consumedAt: timestamp("consumed_at", { withTimezone: true }),
});
/** Structured clone progress events (mirrors in-memory backend event stream). */
export const jobEvents = pgTable("job_events", {
id: uuid("id").defaultRandom().primaryKey(),
jobId: uuid("job_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
seq: integer("seq").notNull(),
payload: jsonb("payload").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => [index("job_events_job_id_seq_idx").on(t.jobId, t.seq)]);
export type Job = typeof jobs.$inferSelect;
export type NewJob = typeof jobs.$inferInsert;
export type Clone = typeof clones.$inferSelect;
@@ -74,3 +85,4 @@ export type CacheRow = typeof cache.$inferSelect;
export type ApiKey = typeof apiKeys.$inferSelect;
export type SignupToken = typeof signupTokens.$inferSelect;
export type NewSignupToken = typeof signupTokens.$inferInsert;
export type JobEvent = typeof jobEvents.$inferSelect;