@specific.dev/spectest 0.86.0 → 0.86.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,7 +105,7 @@ export interface SupabaseOptions {
105
105
  * ```
106
106
  * supabase/
107
107
  * config.toml ← auth rules, mail templates, per-function JWT verification
108
- * migrations/ ← applied in filename order once the stack is up
108
+ * migrations/ ← applied after Auth/Storage/Realtime, before REST
109
109
  * seed.sql ← applied after them, if it's there
110
110
  * functions/ ← one directory per function, served at /functions/v1/<name>
111
111
  * ```
@@ -1116,15 +1116,21 @@ function buildKongYaml(key, vals) {
1116
1116
  .join("\n");
1117
1117
  return y;
1118
1118
  }
1119
+ // ──────────────────────────────────────────────────────────────────────────
1120
+ // Project SQL runs after Auth, Storage and Realtime have created their own
1121
+ // schemas, but before PostgREST starts. The migrations part is a dependency
1122
+ // barrier: its setup finishes before REST and the gateway can become ready.
1123
+ // This prevents the DDL notifications from rebuilding a half-built schema.
1124
+ // ──────────────────────────────────────────────────────────────────────────
1125
+ /** Read-only project SQL inside the database container. */
1126
+ const SQL_MOUNT = "/spectest/supabase";
1119
1127
  /** Pipe one SQL file's contents into `psql` inside the db container. Uses
1120
1128
  * `psql -f -` (stdin) so multi-statement files and non-transactional DDL
1121
1129
  * (e.g. `CREATE INDEX CONCURRENTLY`) behave exactly as `supabase db reset`
1122
1130
  * applies them — not wrapped in a single transaction. `ON_ERROR_STOP=1` fails
1123
1131
  * fast on the first bad statement. */
1124
1132
  async function applySqlFile(ctx, sql, label, user,
1125
- /** The db service to run `psql` in. Defaults to the hook's own service,
1126
- * which is the db only for the bootstrap; the project's migrations run
1127
- * from the group hook, where `ctx.name` is the gateway. */
1133
+ /** The db service to run `psql` in; defaults to the hook's own service. */
1128
1134
  service = ctx.name) {
1129
1135
  const res = await ctx.exec(service, ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", "postgres", "-f", "-"], { stdin: sql, timeoutMs: 300_000 });
1130
1136
  if (res.exitCode !== 0) {
@@ -1139,7 +1145,7 @@ service = ctx.name) {
1139
1145
  * Only ever called with a path the user gave: there is no assumed location,
1140
1146
  * so this never applies SQL nobody asked for. */
1141
1147
  async function applyMigrations(ctx, migrations,
1142
- /** Where `psql` runs — the db part, named from the group hook. */
1148
+ /** Where `psql` runs — the db part, named from the migration hook. */
1143
1149
  db) {
1144
1150
  const dir = migrations.startsWith("/")
1145
1151
  ? migrations
@@ -1153,97 +1159,34 @@ db) {
1153
1159
  `.spectestignore).`);
1154
1160
  }
1155
1161
  const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
1156
- for (const f of files) {
1157
- const sql = await ctx.readProjectFile(join(dir, f));
1158
- await applySqlFile(ctx, sql, `migration ${f}`, "postgres", db);
1159
- }
1160
- // `seed.sql` sits next to the migrations directory in the layout the
1161
- // Supabase CLI creates, so it needs no option of its own — pointing at
1162
- // the migrations is enough to say where the project's SQL lives. Absent
1163
- // is fine: plenty of projects have migrations and no seed.
1162
+ const paths = files.map((f) => `${SQL_MOUNT}/migrations/${f}`);
1164
1163
  const seedPath = join(dirname(dir), "seed.sql");
1165
- if (existsSync(seedPath)) {
1166
- const sql = await ctx.readProjectFile(seedPath);
1167
- await applySqlFile(ctx, sql, "seed.sql", "postgres", db);
1168
- }
1169
- }
1170
- /**
1171
- * Make PostgREST serve the schema the migrations just created, and do not
1172
- * return until it does so **stably**.
1173
- *
1174
- * PostgREST reads the schema once, at boot, and now boots before the
1175
- * project's migrations run. Without this, the first REST call for a new
1176
- * table answers `PGRST205 Could not find the table` — the failure the user
1177
- * would otherwise have to diagnose and work around themselves.
1178
- *
1179
- * Two things make this more than one request:
1180
- *
1181
- * - **A rebuild is not atomic from the outside.** While PostgREST reloads,
1182
- * a request can still miss the table. So a single successful check is not
1183
- * proof; the cache has to answer correctly several times in a row.
1184
- * - **Reloads are already in flight.** The `supabase/postgres` image ships
1185
- * `pgrst_ddl_watch`, an event trigger that notifies on every DDL, so a
1186
- * migration run queues several reloads of its own. Ours goes *first* and
1187
- * the settle loop then waits them all out — checking before sending the
1188
- * notify was a real bug: the poll passed, our own notify landed after it,
1189
- * and a test raced the rebuild it caused.
1190
- *
1191
- * The expectation comes from the database itself: every base table in the
1192
- * exposed `public` schema should be listed. A stack with no public tables
1193
- * has nothing to wait for.
1194
- */
1195
- async function reloadPostgrestSchema(ctx, db, rest) {
1196
- // Ours first, so every reload — ours and the DDL trigger's — is already
1197
- // queued before we start watching for the result.
1198
- await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
1199
- // Only the tables PostgREST will actually publish. The spec below is
1200
- // fetched unauthenticated, so it is the `anon` role's view — and a table
1201
- // deliberately granted to nobody else (Supabase projects do this routinely:
1202
- // a lookup table only `supabase_auth_admin` may read, RLS with no anon
1203
- // grant) never appears in it. Waiting for those would wait for ever, on a
1204
- // schema cache that is in fact up to date.
1205
- const listed = await ctx.exec(db, [
1206
- "psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
1207
- "select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace " +
1208
- "where n.nspname = 'public' and c.relkind = 'r' " +
1209
- // `anon` is created by this component's own bootstrap, so it exists.
1210
- "and has_table_privilege('anon', c.oid, 'SELECT') order by 1",
1211
- ], { timeoutMs: 60_000 });
1212
- const tables = String(listed.stdout)
1213
- .split("\n")
1214
- .map((t) => t.trim())
1215
- .filter(Boolean);
1216
- if (tables.length === 0)
1164
+ if (existsSync(seedPath))
1165
+ paths.push(`${SQL_MOUNT}/seed.sql`);
1166
+ if (paths.length === 0)
1217
1167
  return;
1218
- /** One check: are all the tables served right now? */
1219
- const served = async () => {
1220
- const res = await fetch(`http://${rest}:3000/`);
1221
- if (!res.ok)
1222
- return tables;
1223
- const spec = (await res.json());
1224
- const paths = Object.keys(spec.paths ?? {});
1225
- return tables.filter((t) => !paths.includes(`/${t}`));
1226
- };
1227
- const SETTLE = 3;
1228
- const deadline = Date.now() + 30_000;
1229
- let streak = 0;
1230
- let missing = tables;
1231
- while (Date.now() < deadline) {
1232
- try {
1233
- missing = await served();
1234
- streak = missing.length === 0 ? streak + 1 : 0;
1235
- if (streak >= SETTLE)
1236
- return;
1237
- }
1238
- catch {
1239
- // Mid-reload PostgREST can refuse the connection outright.
1240
- streak = 0;
1168
+ // Repeated -f keeps psql's file boundaries (COPY, EOF without a semicolon,
1169
+ // and filename:line diagnostics), with one docker exec and one connection.
1170
+ // Do not use --single-transaction: concurrent indexes and explicit
1171
+ // transaction control in existing migrations must continue to work.
1172
+ const command = ["psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", "postgres"];
1173
+ for (const path of paths) {
1174
+ if (path !== paths[0]) {
1175
+ // Each file previously had its own session. Roll back unfinished work
1176
+ // and discard roles, GUCs, temporary tables, prepared statements and
1177
+ // advisory locks before the next file. Separate -c calls are essential:
1178
+ // DISCARD ALL cannot execute inside a multi-statement transaction.
1179
+ command.push("-c", "\\set ON_ERROR_STOP on", "-c", "\\set AUTOCOMMIT on");
1180
+ command.push("-c", "ROLLBACK", "-c", "DISCARD ALL");
1241
1181
  }
1242
- await new Promise((r) => setTimeout(r, 150));
1182
+ command.push("-f", path);
1243
1183
  }
1244
- throw new Error(`supabase: PostgREST did not settle on the migrated schema within 30s ` +
1245
- `(still missing: ${missing.join(", ") || "nothing, but not stably"}). ` +
1246
- `The migrations applied — this is the REST schema cache, not your SQL.`);
1184
+ const started = Date.now();
1185
+ const res = await ctx.exec(db, command, { timeoutMs: 300_000 });
1186
+ if (res.exitCode !== 0) {
1187
+ throw new Error(`supabase: applying migrations/seed failed (psql rc=${res.exitCode}):\n${res.stderr.trim() || res.stdout.trim()}`);
1188
+ }
1189
+ console.log(`supabase(): applied ${files.length} migrations${paths.length > files.length ? " and seed.sql" : ""} in ${((Date.now() - started) / 1000).toFixed(1)}s.`);
1247
1190
  }
1248
1191
  /**
1249
1192
  * A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
@@ -1496,6 +1439,11 @@ export function supabase(opts = {}) {
1496
1439
  JWT_SECRET: jwtSecret,
1497
1440
  JWT_EXP: "3600",
1498
1441
  },
1442
+ // Read SQL directly, including large seeds, without copying it through
1443
+ // the harness and docker exec stdin. Keep this out of PGDATA.
1444
+ ...(migrationsPath === null ? {} : {
1445
+ volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true }],
1446
+ }),
1499
1447
  ports: [5432],
1500
1448
  // `pg_isready` succeeds only once the image finishes initdb + its
1501
1449
  // baked init-scripts and starts serving for real.
@@ -1504,8 +1452,7 @@ export function supabase(opts = {}) {
1504
1452
  // Only the database's own bootstrap (role passwords / JWT GUC /
1505
1453
  // `_realtime` schema) runs here: the other services need it before
1506
1454
  // they can start. The project's migrations run later, from the
1507
- // group hook, once the whole stack is up — see the migration
1508
- // section above for why.
1455
+ // migrations hook, once the schema-owning services are up.
1509
1456
  await applySqlFile(ctx, buildBootstrapSql(dbPassword, jwtSecret, withRealtime), "supabase bootstrap",
1510
1457
  // The bootstrap alters reserved roles (authenticator, …) and the
1511
1458
  // database — superuser-only. `postgres` is deliberately NOT a
@@ -1537,10 +1484,10 @@ export function supabase(opts = {}) {
1537
1484
  ...(mapped?.rest ?? {}),
1538
1485
  },
1539
1486
  ports: [3000],
1540
- dependsOn: ["db"],
1541
- // PostgREST opens :3000 only after connecting to the DB and loading
1542
- // the schema cache, so a TCP connect is a good ready signal.
1543
- readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
1487
+ dependsOn: [migrationsPath === null ? "db" : "migrations"],
1488
+ // The HTTP socket can open before schema loading completes. /ready
1489
+ // checks both the database pool and the populated schema cache.
1490
+ readyCheck: { type: "http", port: 3001, path: "/ready", timeoutSecs: 120 },
1544
1491
  };
1545
1492
  // ── mail (SMTP capture, the standard email() component) ─────────
1546
1493
  if (withMail && !mailExternal) {
@@ -1719,7 +1666,9 @@ export function supabase(opts = {}) {
1719
1666
  },
1720
1667
  volumes: [storageData],
1721
1668
  ports: [5000],
1722
- dependsOn: ["db", "rest", "imgproxy"],
1669
+ // Storage initializes and serves /status using Postgres directly;
1670
+ // POSTGREST_URL does not require REST to run during its bootstrap.
1671
+ dependsOn: ["db", "imgproxy"],
1723
1672
  readyCheck: { type: "http", port: 5000, path: "/status", timeoutSecs: 90 },
1724
1673
  };
1725
1674
  }
@@ -1846,6 +1795,20 @@ export function supabase(opts = {}) {
1846
1795
  };
1847
1796
  parts.functions = functionsDef;
1848
1797
  }
1798
+ if (migrationsPath !== null) {
1799
+ parts.migrations = {
1800
+ // Reuse an already required image for this lightweight setup barrier.
1801
+ image: dbDef.image,
1802
+ command: "exec sleep infinity",
1803
+ dependsOn: [
1804
+ "db",
1805
+ ...(withAuth ? ["auth"] : []),
1806
+ ...(withStorage ? ["storage"] : []),
1807
+ ...(withRealtime ? ["realtime"] : []),
1808
+ ],
1809
+ setup: (ctx) => applyMigrations(ctx, migrationsPath, db),
1810
+ };
1811
+ }
1849
1812
  // ── kong (gateway / primary) — no explicit dependsOn: the group
1850
1813
  // expansion makes the primary depend on every member ──────────────
1851
1814
  parts.gateway = {
@@ -1879,16 +1842,6 @@ export function supabase(opts = {}) {
1879
1842
  };
1880
1843
  return parts;
1881
1844
  },
1882
- // The project's own SQL, applied with the whole stack up (the group
1883
- // hook chains after the primary, and the primary is the group's
1884
- // dependency sink). Still inside bring-up, so the result is captured
1885
- // into the cached environment like everything else.
1886
- setup: async (ctx) => {
1887
- if (migrationsPath === null)
1888
- return;
1889
- await applyMigrations(ctx, migrationsPath, dbKey);
1890
- await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
1891
- },
1892
1845
  // The group's consolidated handle at `ctx.svc.<name>`.
1893
1846
  helpers: () => ({
1894
1847
  sql: new SQL(adminDbUrl, { label: name }),
package/dist/daemon.js CHANGED
@@ -15,8 +15,9 @@
15
15
  // One sandbox = one daemon. Concurrency between tests is achieved by
16
16
  // forking the sandbox; inside a single daemon we never run two tests at
17
17
  // once — that keeps stdout capture and timeouts simple.
18
+ import { preparedImages } from "./harness/prepared-images.js";
18
19
  import { execFile, spawn } from "node:child_process";
19
- import { randomUUID } from "node:crypto";
20
+ import { createHash, randomUUID } from "node:crypto";
20
21
  import { existsSync, promises as fs, readFileSync } from "node:fs";
21
22
  import net from "node:net";
22
23
  import path from "node:path";
@@ -2947,23 +2948,31 @@ function logBootstrapTimings(t) {
2947
2948
  }
2948
2949
  console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
2949
2950
  }
2950
- async function bootstrap() {
2951
+ async function bootstrap(prepared) {
2951
2952
  // Tee everything this bootstrap logs — ours and every service `setup`
2952
2953
  // hook's — into the boot log, so GET /boot-log can explain a slow or
2953
2954
  // finished boot. Restored in the finally at the end.
2954
2955
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2955
2956
  try {
2956
- return await bootstrapInner();
2957
+ return await bootstrapInner(prepared);
2957
2958
  }
2958
2959
  finally {
2959
2960
  restoreBootLog();
2960
2961
  }
2961
2962
  }
2962
- async function bootstrapInner() {
2963
+ async function imageInput(svc) {
2964
+ const input = svc.image.type === "registry" ? svc.image : await resolveDockerfileBuild(svc);
2965
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex");
2966
+ }
2967
+ async function bootstrapInner(prepared, identity) {
2963
2968
  const bootStart = Date.now();
2964
2969
  const cfg = requireLoaded().project.environment;
2965
2970
  const services = namedServices(cfg);
2966
2971
  const timings = new Map();
2972
+ if (prepared && (prepared.version !== 1 || !prepared.images ||
2973
+ Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
2974
+ throw new Error("prepared image manifest does not match the environment's services");
2975
+ }
2967
2976
  progressInit(services);
2968
2977
  // Build dedup is only valid within one workspace generation — a fresh
2969
2978
  // bootstrap may follow a workspace re-upload with the same dockerfile
@@ -3010,11 +3019,25 @@ async function bootstrapInner() {
3010
3019
  const tags = new Map();
3011
3020
  // Bootstrap is the only dedup scope: all its builds share one
3012
3021
  // /workspace generation (see prepareServiceImage).
3013
- const prepImage = (svc) => prepareServiceImage(svc, { dedup: true });
3022
+ const prepImage = async (svc) => {
3023
+ if (!prepared)
3024
+ return prepareServiceImage(svc, { dedup: true });
3025
+ const entry = prepared.images[svc.name];
3026
+ if (!entry || entry.input !== await imageInput(svc) || !/^sha256:[a-f0-9]{64}$/.test(entry.image)) {
3027
+ throw new Error(`prepared image inputs changed for ${svc.name}`);
3028
+ }
3029
+ const found = await docker(["image", "inspect", "--format", "{{.Id}}", entry.image], 30_000);
3030
+ if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3031
+ throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3032
+ }
3033
+ return { tag: entry.image, buildSteps: entry.buildSteps };
3034
+ };
3014
3035
  const prep = new Map();
3036
+ const prepOffsets = new Map();
3015
3037
  for (const svc of services) {
3016
3038
  const p = (async () => {
3017
3039
  const t0 = Date.now();
3040
+ prepOffsets.set(svc.name, Date.now() - bootStart);
3018
3041
  const { tag, buildSteps } = await prepImage(svc);
3019
3042
  progressService(svc.name, { status: "prepared", detail: undefined });
3020
3043
  tags.set(svc.name, tag);
@@ -3032,6 +3055,21 @@ async function bootstrapInner() {
3032
3055
  p.catch(() => undefined);
3033
3056
  prep.set(svc.name, p);
3034
3057
  }
3058
+ if (identity !== undefined) {
3059
+ // No application services, setup hooks, ingress, or runtime state in the builder.
3060
+ await Promise.all(prep.values());
3061
+ const images = Object.create(null);
3062
+ for (const svc of services) {
3063
+ const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)], 30_000);
3064
+ const image = out.stdout.trim();
3065
+ if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
3066
+ throw new Error(`cannot resolve prepared image for ${svc.name}: ${out.stderr}`);
3067
+ }
3068
+ const ti = timings.get(svc.name);
3069
+ images[svc.name] = { input: await imageInput(svc), image, prepMs: ti.prepMs, startMs: prepOffsets.get(svc.name), buildSteps: ti.buildSteps };
3070
+ }
3071
+ return { version: 1, identity, images };
3072
+ }
3035
3073
  // Ingress (fakes + service-tls proxies) comes up BEFORE services so
3036
3074
  // that any service that calls a fake URL during its own startup
3037
3075
  // probe finds it answering. Service-tls proxies will return 502
@@ -3488,8 +3526,13 @@ async function writeCoverageInventory(svc) {
3488
3526
  },
3489
3527
  projectFiles: () => walkProjectFiles(WORKSPACE),
3490
3528
  async readProjectFile(p) {
3529
+ // Not recorded as a read: an inventory fingerprinting every
3530
+ // manifest is bookkeeping, not the suite depending on the files.
3531
+ // Recording them made the setup set name every package.json as a
3532
+ // whole file, so a version bump was "ran during environment
3533
+ // setup" — a full run, the exact case the inventory exists for.
3491
3534
  try {
3492
- return await fs.readFile(resolveProjectPath(p), "utf8");
3535
+ return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
3493
3536
  }
3494
3537
  catch {
3495
3538
  return null;
@@ -3521,9 +3564,22 @@ async function writeCoverageInventory(svc) {
3521
3564
  * The project's reach (`harness/reach.ts`): every project file some
3522
3565
  * service's build context includes, some volume mounts, or a build
3523
3566
  * reads. Computed from the same resolution the builds use, once at the
3524
- * bring-up capture.
3567
+ * bring-up capture. The resolution's own path lookups are bookkeeping,
3568
+ * not reads: they are taken out of the read set again afterwards.
3525
3569
  */
3526
3570
  async function computeProjectReach(all) {
3571
+ const before = takeProjectReads();
3572
+ try {
3573
+ return await computeProjectReachInner(all);
3574
+ }
3575
+ finally {
3576
+ // Discard what the resolution recorded, restore what was there.
3577
+ takeProjectReads();
3578
+ for (const p of before)
3579
+ resolveProjectPath(p);
3580
+ }
3581
+ }
3582
+ async function computeProjectReachInner(all) {
3527
3583
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
3528
3584
  const sources = [];
3529
3585
  for (const svc of all) {
@@ -5398,7 +5454,7 @@ function loadedSummary(l) {
5398
5454
  */
5399
5455
  export function harnessMethods(state) {
5400
5456
  return {
5401
- health: async () => ({ ok: true }),
5457
+ health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
5402
5458
  // Live bootstrap progress, polled by the control plane during
5403
5459
  // `bootstrap` and streamed into the test-run row. `{}` before the
5404
5460
  // first bootstrap of this harness process.
@@ -5497,10 +5553,19 @@ export function harnessMethods(state) {
5497
5553
  }
5498
5554
  return { refs: [...refs] };
5499
5555
  },
5500
- bootstrap: async () => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
5501
- ok: true,
5502
- timings: await bootstrap(),
5503
- })),
5556
+ prepareImages: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
5557
+ const restore = captureConsole(BOOT_LOG_SINK);
5558
+ try {
5559
+ return await bootstrapInner(undefined, requireString(params, "identity"));
5560
+ }
5561
+ finally {
5562
+ restore();
5563
+ }
5564
+ }),
5565
+ bootstrap: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
5566
+ const prepared = params.prepared === undefined ? undefined : preparedImages(params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name));
5567
+ return { ok: true, timings: await bootstrap(prepared) };
5568
+ }),
5504
5569
  projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
5505
5570
  eval: async (params) => {
5506
5571
  const code = requireString(params, "code");
@@ -0,0 +1,14 @@
1
+ import type { BuildStep } from "./buildkit-progress.js";
2
+ export interface PreparedImages {
3
+ version: 1;
4
+ identity: string;
5
+ images: Record<string, {
6
+ input: string;
7
+ image: string;
8
+ prepMs: number;
9
+ startMs?: number;
10
+ buildSteps?: BuildStep[];
11
+ }>;
12
+ }
13
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
14
+ export declare function preparedImages(value: unknown, identity: unknown, names: string[]): PreparedImages;
@@ -0,0 +1,18 @@
1
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
2
+ export function preparedImages(value, identity, names) {
3
+ const manifest = value;
4
+ if (!manifest || manifest.version !== 1 || typeof identity !== "string" || !identity || manifest.identity !== identity) {
5
+ throw new Error("prepared image source/SDK identity mismatch");
6
+ }
7
+ if (!manifest.images || typeof manifest.images !== "object" ||
8
+ Object.keys(manifest.images).sort().join("\n") !== [...names].sort().join("\n")) {
9
+ throw new Error("prepared image manifest does not match the environment's services");
10
+ }
11
+ for (const name of names) {
12
+ const entry = manifest.images[name];
13
+ if (!entry || !/^sha256:[a-f0-9]{64}$/.test(entry.image) || !/^[a-f0-9]{64}$/.test(entry.input)) {
14
+ throw new Error(`invalid prepared image for ${name}`);
15
+ }
16
+ }
17
+ return manifest;
18
+ }
@@ -14,7 +14,9 @@ export declare function takeProjectReads(): string[];
14
14
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
15
15
  * reporting ENOENT the way it always has.
16
16
  */
17
- export declare function resolveProjectPath(p: string): string;
17
+ export declare function resolveProjectPath(p: string, opts?: {
18
+ record?: boolean;
19
+ }): string;
18
20
  /** {@link resolveProjectPath}, but a missing file is an error that names every
19
21
  * place we looked. For inputs a user hands us by name — a fixture — where an
20
22
  * ENOENT on one guessed path reads as a spectest bug rather than a typo. */
@@ -74,11 +74,16 @@ function relative(p) {
74
74
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
75
75
  * reporting ENOENT the way it always has.
76
76
  */
77
- export function resolveProjectPath(p) {
77
+ export function resolveProjectPath(p, opts = {}) {
78
78
  if (path.isAbsolute(p))
79
79
  return p;
80
80
  const rel = relative(p);
81
- PROJECT_READS.add(rel);
81
+ // A read is coverage of the file for the case that made it — unless
82
+ // the caller says otherwise: the harness's own bookkeeping reads (a
83
+ // coverage inventory fingerprinting every manifest) are not the
84
+ // suite's dependence on those files.
85
+ if (opts.record !== false)
86
+ PROJECT_READS.add(rel);
82
87
  if (rel.startsWith("spectest/")) {
83
88
  const app = path.join(APP_DIR, rel);
84
89
  if (existsSync(app))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.86.0",
3
+ "version": "0.86.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,7 +113,7 @@ describe("supabase({ dir })", () => {
113
113
  });
114
114
  expect(parts.functions).toBeDefined();
115
115
  expect(parts.templates).toBeDefined();
116
- expect(stack.group.setup).toBeDefined();
116
+ expect(parts.migrations?.setup).toBeDefined();
117
117
  });
118
118
 
119
119
  test("an unresolved HTTP hook secret fails with its config key and env name", () => {
@@ -0,0 +1,116 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ExecOpts, ServiceSetupContext } from "../index.js";
6
+ import { supabase, type SupabaseOptions } from "./supabase.js";
7
+
8
+ const roots: string[] = [];
9
+ function fixture(files: Record<string, string> = {}) {
10
+ const root = mkdtempSync(join(tmpdir(), "supabase-migrations-"));
11
+ roots.push(root);
12
+ mkdirSync(join(root, "migrations"));
13
+ for (const [name, sql] of Object.entries(files)) writeFileSync(join(root, name), sql);
14
+ return root;
15
+ }
16
+ afterEach(() => {
17
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
18
+ });
19
+
20
+ function parts(dir: string, options: SupabaseOptions = {}) {
21
+ const name = options.name ?? "supabase";
22
+ return supabase({ dir, ...options }).group.services({
23
+ name, key: (part) => part === "gateway" ? name : `${name}-${part}`,
24
+ });
25
+ }
26
+
27
+ test("SQL waits for schema owners; REST waits for SQL without a storage cycle", () => {
28
+ for (const auth of [false, true]) for (const storage of [false, true]) for (const realtime of [false, true]) {
29
+ const services = parts(fixture(), { auth, storage, realtime, name: "custom" });
30
+ expect(services.migrations!.dependsOn).toEqual([
31
+ "db", ...(auth ? ["auth"] : []), ...(storage ? ["storage"] : []), ...(realtime ? ["realtime"] : []),
32
+ ]);
33
+ expect(services.rest!.dependsOn).toEqual(["migrations"]);
34
+ expect(services.rest!.readyCheck).toMatchObject({ type: "http", port: 3001, path: "/ready" });
35
+ if (storage) expect(services.storage!.dependsOn).toEqual(["db", "imgproxy"]);
36
+ }
37
+ });
38
+
39
+ test("hundreds of sorted migrations and a seed use one exec without reading SQL into the harness", async () => {
40
+ const root = fixture({ "seed.sql": "select 1" });
41
+ for (let i = 558; i >= 0; i--) writeFileSync(join(root, "migrations", `${String(i).padStart(4, "0")}.sql`), "select 1");
42
+ writeFileSync(join(root, "migrations", "ignored.txt"), "not SQL");
43
+ const services = parts(root);
44
+ expect(services.db!.volumes).toContainEqual({ source: root, target: "/spectest/supabase", readOnly: true });
45
+ const calls: { service: string; command: string[]; opts?: ExecOpts }[] = [];
46
+ await services.migrations!.setup!({
47
+ exec: async (service: string, command: string[], opts?: ExecOpts) => {
48
+ calls.push({ service, command, opts });
49
+ return { exitCode: 0, stdout: "", stderr: "" };
50
+ },
51
+ } as ServiceSetupContext);
52
+ expect(calls).toHaveLength(1);
53
+ expect(calls[0]!.service).toBe("supabase-db");
54
+ expect(calls[0]!.opts?.stdin).toBeUndefined();
55
+ const command = calls[0]!.command;
56
+ const files = command.flatMap((arg, i) => arg === "-f" ? [command[i + 1]] : []);
57
+ expect(files).toHaveLength(560);
58
+ expect(files[0]).toBe("/spectest/supabase/migrations/0000.sql");
59
+ expect(files.at(-2)).toBe("/spectest/supabase/migrations/0558.sql");
60
+ expect(files.at(-1)).toBe("/spectest/supabase/seed.sql");
61
+ });
62
+
63
+ test("an empty SQL directory skips exec; a failed batch preserves psql diagnostics", async () => {
64
+ const root = fixture();
65
+ let calls = 0;
66
+ const ctx = { exec: async () => {
67
+ calls++;
68
+ return { exitCode: 3, stdout: "", stderr: "psql:/spectest/supabase/migrations/bad.sql:7: ERROR: broken" };
69
+ } } as unknown as ServiceSetupContext;
70
+ await parts(root).migrations!.setup!(ctx);
71
+ expect(calls).toBe(0);
72
+ writeFileSync(join(root, "migrations", "bad.sql"), "broken");
73
+ await expect(parts(root).migrations!.setup!(ctx)).rejects.toThrow("bad.sql:7: ERROR: broken");
74
+ });
75
+
76
+ // Opt in to a disposable container; no existing database is touched.
77
+ describe.skipIf(process.env.SPECTEST_TEST_SUPABASE_MIGRATIONS !== "1")("real psql migration batching", () => {
78
+ test("preserves file boundaries, COPY, nontransactional DDL, isolation and fail-fast errors", async () => {
79
+ const root = fixture({
80
+ "migrations/01.sql": "CREATE TABLE public.batch_probe (id int); INSERT INTO public.batch_probe VALUES (1)",
81
+ "migrations/02.sql": "COPY public.batch_probe FROM stdin;\n2\n3\n\\.\nCREATE INDEX CONCURRENTLY batch_idx ON public.batch_probe(id);\nCREATE TEMP TABLE batch_temp (id int); SET search_path = pg_catalog; SET ROLE anon;",
82
+ "migrations/03 'quoted.sql": "DO $$ BEGIN IF current_user <> 'postgres' OR to_regclass('batch_temp') IS NOT NULL THEN RAISE EXCEPTION 'session leaked'; END IF; END $$;\nINSERT INTO batch_probe VALUES (4); BEGIN; INSERT INTO batch_probe VALUES (99);",
83
+ "seed.sql": "INSERT INTO batch_probe VALUES (5);",
84
+ });
85
+ const name = `spectest-batch-test-${process.pid}`;
86
+ async function docker(args: string[], stdin = "") {
87
+ const child = Bun.spawn(["sudo", "docker", ...args], { stdin: new Blob([stdin]), stdout: "pipe", stderr: "pipe" });
88
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
89
+ return { stdout, stderr, exitCode };
90
+ }
91
+ const services = parts(root, { db: { tls: false } });
92
+ const ctx = { exec: (_service: string, command: string[], opts?: ExecOpts) => docker(["exec", "-i", name, ...command], opts?.stdin) } as ServiceSetupContext;
93
+ const sql = (statement: string) => ctx.exec(name, ["psql", "-XAt", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", "postgres", "-c", statement]);
94
+ try {
95
+ const started = await docker(["run", "-d", "--name", name, "-e", "POSTGRES_PASSWORD=postgres", "-v", `${root}:/spectest/supabase:ro`, "supabase/postgres:17.6.1.136"]);
96
+ if (started.exitCode) throw new Error(started.stderr);
97
+ let ready = false;
98
+ for (let i = 0; i < 120; i++) {
99
+ if ((await docker(["exec", name, "pg_isready", "-U", "postgres", "-h", "127.0.0.1"])).exitCode === 0) { ready = true; break; }
100
+ await Bun.sleep(500);
101
+ }
102
+ expect(ready).toBe(true);
103
+ await services.db!.setup!({ ...ctx, name } as ServiceSetupContext);
104
+ await services.migrations!.setup!(ctx);
105
+ expect((await sql("SELECT array_agg(id ORDER BY id) FROM batch_probe")).stdout.trim()).toBe("{1,2,3,4,5}");
106
+ rmSync(join(root, "migrations"), { recursive: true });
107
+ mkdirSync(join(root, "migrations"));
108
+ writeFileSync(join(root, "migrations", "broken.sql"), "SELECT 1;\nSELECT * FROM missing_batch_table;\n");
109
+ writeFileSync(join(root, "seed.sql"), "INSERT INTO batch_probe VALUES (6)");
110
+ await expect(services.migrations!.setup!(ctx)).rejects.toThrow(/broken.sql:2: ERROR/);
111
+ expect((await sql("SELECT count(*) FROM batch_probe")).stdout.trim()).toBe("5");
112
+ } finally {
113
+ await docker(["rm", "-fv", name]);
114
+ }
115
+ }, 180_000);
116
+ });
@@ -56,7 +56,6 @@ import { dirname, join } from "node:path";
56
56
 
57
57
  import {
58
58
  serviceGroup,
59
- type ServiceConfig,
60
59
  type ServiceDefinition,
61
60
  type ServiceGroup,
62
61
  type ServiceSetupContext,
@@ -318,7 +317,7 @@ export interface SupabaseOptions {
318
317
  * ```
319
318
  * supabase/
320
319
  * config.toml ← auth rules, mail templates, per-function JWT verification
321
- * migrations/ ← applied in filename order once the stack is up
320
+ * migrations/ ← applied after Auth/Storage/Realtime, before REST
322
321
  * seed.sql ← applied after them, if it's there
323
322
  * functions/ ← one directory per function, served at /functions/v1/<name>
324
323
  * ```
@@ -1517,34 +1516,16 @@ function buildKongYaml(
1517
1516
  }
1518
1517
 
1519
1518
  // ──────────────────────────────────────────────────────────────────────────
1520
- // Migration application. Runs in the **group's** `setup`, which chains after
1521
- // the primary (the gateway), and the gateway is the group's dependency sink
1522
- // — so by then every service is up and has finished its own bootstrap. That
1523
- // ordering is the whole point:
1524
- //
1525
- // Several services own schema of their own and create it when THEY boot,
1526
- // not when the database does. storage-api creates `storage.buckets` /
1527
- // `storage.objects`; GoTrue creates the modern `auth.*` tables
1528
- // (`identities`, `sessions`, `mfa_*`, `one_time_tokens`); realtime creates
1529
- // its own. The stock `supabase/postgres` image ships only the legacy auth
1530
- // stubs (`auth.users` and friends) and nothing at all under `storage`.
1531
- //
1532
- // Applied any earlier, an ordinary migration — `insert into
1533
- // storage.buckets`, a policy on `auth.identities` — fails with `relation
1534
- // … does not exist`, and the error points at the user's SQL rather than at
1535
- // the ordering. This is also the point at which `supabase db reset`
1536
- // applies migrations: against a stack that is already running.
1537
- //
1538
- // The database's OWN bootstrap (roles, JWT GUC, `_realtime` schema) stays in
1539
- // the db service's `setup`, because the other services need it to start at
1540
- // all.
1541
- //
1542
- // Uses the setup context's `exec` (docker exec with piped stdin) and
1543
- // `projectRoot` — no hand-rolled child_process, no hard-coded workspace path.
1519
+ // Project SQL runs after Auth, Storage and Realtime have created their own
1520
+ // schemas, but before PostgREST starts. The migrations part is a dependency
1521
+ // barrier: its setup finishes before REST and the gateway can become ready.
1522
+ // This prevents the DDL notifications from rebuilding a half-built schema.
1544
1523
  // ──────────────────────────────────────────────────────────────────────────
1545
1524
 
1546
- /** Either setup hook's context: the db part's (no helpers) or the group's
1547
- * (the consolidated handle). Nothing below reads `helpers`. */
1525
+ /** Read-only project SQL inside the database container. */
1526
+ const SQL_MOUNT = "/spectest/supabase";
1527
+
1528
+ /** Shared by the database bootstrap and project migration hooks. */
1548
1529
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1549
1530
  type SetupCtx = ServiceSetupContext<any>;
1550
1531
 
@@ -1558,9 +1539,7 @@ async function applySqlFile(
1558
1539
  sql: string,
1559
1540
  label: string,
1560
1541
  user: string,
1561
- /** The db service to run `psql` in. Defaults to the hook's own service,
1562
- * which is the db only for the bootstrap; the project's migrations run
1563
- * from the group hook, where `ctx.name` is the gateway. */
1542
+ /** The db service to run `psql` in; defaults to the hook's own service. */
1564
1543
  service: string = ctx.name,
1565
1544
  ): Promise<void> {
1566
1545
  const res = await ctx.exec(
@@ -1587,7 +1566,7 @@ async function applySqlFile(
1587
1566
  async function applyMigrations(
1588
1567
  ctx: SetupCtx,
1589
1568
  migrations: string,
1590
- /** Where `psql` runs — the db part, named from the group hook. */
1569
+ /** Where `psql` runs — the db part, named from the migration hook. */
1591
1570
  db: string,
1592
1571
  ): Promise<void> {
1593
1572
  const dir = migrations.startsWith("/")
@@ -1605,108 +1584,37 @@ async function applyMigrations(
1605
1584
  }
1606
1585
 
1607
1586
  const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
1608
- for (const f of files) {
1609
- const sql = await ctx.readProjectFile(join(dir, f));
1610
- await applySqlFile(ctx, sql, `migration ${f}`, "postgres", db);
1611
- }
1612
-
1613
- // `seed.sql` sits next to the migrations directory in the layout the
1614
- // Supabase CLI creates, so it needs no option of its own — pointing at
1615
- // the migrations is enough to say where the project's SQL lives. Absent
1616
- // is fine: plenty of projects have migrations and no seed.
1587
+ const paths = files.map((f) => `${SQL_MOUNT}/migrations/${f}`);
1617
1588
  const seedPath = join(dirname(dir), "seed.sql");
1618
- if (existsSync(seedPath)) {
1619
- const sql = await ctx.readProjectFile(seedPath);
1620
- await applySqlFile(ctx, sql, "seed.sql", "postgres", db);
1621
- }
1622
- }
1623
-
1624
- /**
1625
- * Make PostgREST serve the schema the migrations just created, and do not
1626
- * return until it does so **stably**.
1627
- *
1628
- * PostgREST reads the schema once, at boot, and now boots before the
1629
- * project's migrations run. Without this, the first REST call for a new
1630
- * table answers `PGRST205 Could not find the table` — the failure the user
1631
- * would otherwise have to diagnose and work around themselves.
1632
- *
1633
- * Two things make this more than one request:
1634
- *
1635
- * - **A rebuild is not atomic from the outside.** While PostgREST reloads,
1636
- * a request can still miss the table. So a single successful check is not
1637
- * proof; the cache has to answer correctly several times in a row.
1638
- * - **Reloads are already in flight.** The `supabase/postgres` image ships
1639
- * `pgrst_ddl_watch`, an event trigger that notifies on every DDL, so a
1640
- * migration run queues several reloads of its own. Ours goes *first* and
1641
- * the settle loop then waits them all out — checking before sending the
1642
- * notify was a real bug: the poll passed, our own notify landed after it,
1643
- * and a test raced the rebuild it caused.
1644
- *
1645
- * The expectation comes from the database itself: every base table in the
1646
- * exposed `public` schema should be listed. A stack with no public tables
1647
- * has nothing to wait for.
1648
- */
1649
- async function reloadPostgrestSchema(
1650
- ctx: SetupCtx,
1651
- db: string,
1652
- rest: string,
1653
- ): Promise<void> {
1654
- // Ours first, so every reload — ours and the DDL trigger's — is already
1655
- // queued before we start watching for the result.
1656
- await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
1657
-
1658
- // Only the tables PostgREST will actually publish. The spec below is
1659
- // fetched unauthenticated, so it is the `anon` role's view — and a table
1660
- // deliberately granted to nobody else (Supabase projects do this routinely:
1661
- // a lookup table only `supabase_auth_admin` may read, RLS with no anon
1662
- // grant) never appears in it. Waiting for those would wait for ever, on a
1663
- // schema cache that is in fact up to date.
1664
- const listed = await ctx.exec(
1665
- db,
1666
- [
1667
- "psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
1668
- "select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace " +
1669
- "where n.nspname = 'public' and c.relkind = 'r' " +
1670
- // `anon` is created by this component's own bootstrap, so it exists.
1671
- "and has_table_privilege('anon', c.oid, 'SELECT') order by 1",
1672
- ],
1673
- { timeoutMs: 60_000 },
1674
- );
1675
- const tables = String(listed.stdout)
1676
- .split("\n")
1677
- .map((t) => t.trim())
1678
- .filter(Boolean);
1679
- if (tables.length === 0) return;
1680
-
1681
- /** One check: are all the tables served right now? */
1682
- const served = async (): Promise<string[]> => {
1683
- const res = await fetch(`http://${rest}:3000/`);
1684
- if (!res.ok) return tables;
1685
- const spec = (await res.json()) as { paths?: Record<string, unknown> };
1686
- const paths = Object.keys(spec.paths ?? {});
1687
- return tables.filter((t) => !paths.includes(`/${t}`));
1688
- };
1689
-
1690
- const SETTLE = 3;
1691
- const deadline = Date.now() + 30_000;
1692
- let streak = 0;
1693
- let missing: string[] = tables;
1694
- while (Date.now() < deadline) {
1695
- try {
1696
- missing = await served();
1697
- streak = missing.length === 0 ? streak + 1 : 0;
1698
- if (streak >= SETTLE) return;
1699
- } catch {
1700
- // Mid-reload PostgREST can refuse the connection outright.
1701
- streak = 0;
1589
+ if (existsSync(seedPath)) paths.push(`${SQL_MOUNT}/seed.sql`);
1590
+ if (paths.length === 0) return;
1591
+
1592
+ // Repeated -f keeps psql's file boundaries (COPY, EOF without a semicolon,
1593
+ // and filename:line diagnostics), with one docker exec and one connection.
1594
+ // Do not use --single-transaction: concurrent indexes and explicit
1595
+ // transaction control in existing migrations must continue to work.
1596
+ const command = ["psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", "postgres"];
1597
+ for (const path of paths) {
1598
+ if (path !== paths[0]) {
1599
+ // Each file previously had its own session. Roll back unfinished work
1600
+ // and discard roles, GUCs, temporary tables, prepared statements and
1601
+ // advisory locks before the next file. Separate -c calls are essential:
1602
+ // DISCARD ALL cannot execute inside a multi-statement transaction.
1603
+ command.push("-c", "\\set ON_ERROR_STOP on", "-c", "\\set AUTOCOMMIT on");
1604
+ command.push("-c", "ROLLBACK", "-c", "DISCARD ALL");
1702
1605
  }
1703
- await new Promise((r) => setTimeout(r, 150));
1606
+ command.push("-f", path);
1704
1607
  }
1705
- throw new Error(
1706
- `supabase: PostgREST did not settle on the migrated schema within 30s ` +
1707
- `(still missing: ${missing.join(", ") || "nothing, but not stably"}). ` +
1708
- `The migrations applied — this is the REST schema cache, not your SQL.`,
1709
- );
1608
+ const started = Date.now();
1609
+ const res = await ctx.exec(db, command, { timeoutMs: 300_000 });
1610
+ if (res.exitCode !== 0) {
1611
+ throw new Error(
1612
+ `supabase: applying migrations/seed failed (psql rc=${res.exitCode}):\n${
1613
+ res.stderr.trim() || res.stdout.trim()
1614
+ }`,
1615
+ );
1616
+ }
1617
+ console.log(`supabase(): applied ${files.length} migrations${paths.length > files.length ? " and seed.sql" : ""} in ${((Date.now() - started) / 1000).toFixed(1)}s.`);
1710
1618
  }
1711
1619
 
1712
1620
  /**
@@ -1979,7 +1887,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1979
1887
  // be reachable at `realtime-dev.<realtime key>` — the host Kong
1980
1888
  // routes to.
1981
1889
  const realtimeHost = `realtime-dev.${g.key("realtime")}`;
1982
- const parts: Record<string, ServiceConfig> = {};
1890
+ const parts: ServicesMap = {};
1983
1891
 
1984
1892
  // ── db ──────────────────────────────────────────────────────────
1985
1893
  const dbDef: ServiceDefinition = {
@@ -2006,6 +1914,11 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2006
1914
  JWT_SECRET: jwtSecret,
2007
1915
  JWT_EXP: "3600",
2008
1916
  },
1917
+ // Read SQL directly, including large seeds, without copying it through
1918
+ // the harness and docker exec stdin. Keep this out of PGDATA.
1919
+ ...(migrationsPath === null ? {} : {
1920
+ volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true }],
1921
+ }),
2009
1922
  ports: [5432],
2010
1923
  // `pg_isready` succeeds only once the image finishes initdb + its
2011
1924
  // baked init-scripts and starts serving for real.
@@ -2014,8 +1927,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2014
1927
  // Only the database's own bootstrap (role passwords / JWT GUC /
2015
1928
  // `_realtime` schema) runs here: the other services need it before
2016
1929
  // they can start. The project's migrations run later, from the
2017
- // group hook, once the whole stack is up — see the migration
2018
- // section above for why.
1930
+ // migrations hook, once the schema-owning services are up.
2019
1931
  await applySqlFile(
2020
1932
  ctx,
2021
1933
  buildBootstrapSql(dbPassword, jwtSecret, withRealtime),
@@ -2051,10 +1963,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2051
1963
  ...(mapped?.rest ?? {}),
2052
1964
  },
2053
1965
  ports: [3000],
2054
- dependsOn: ["db"],
2055
- // PostgREST opens :3000 only after connecting to the DB and loading
2056
- // the schema cache, so a TCP connect is a good ready signal.
2057
- readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
1966
+ dependsOn: [migrationsPath === null ? "db" : "migrations"],
1967
+ // The HTTP socket can open before schema loading completes. /ready
1968
+ // checks both the database pool and the populated schema cache.
1969
+ readyCheck: { type: "http", port: 3001, path: "/ready", timeoutSecs: 120 },
2058
1970
  };
2059
1971
 
2060
1972
  // ── mail (SMTP capture, the standard email() component) ─────────
@@ -2241,7 +2153,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2241
2153
  },
2242
2154
  volumes: [storageData],
2243
2155
  ports: [5000],
2244
- dependsOn: ["db", "rest", "imgproxy"],
2156
+ // Storage initializes and serves /status using Postgres directly;
2157
+ // POSTGREST_URL does not require REST to run during its bootstrap.
2158
+ dependsOn: ["db", "imgproxy"],
2245
2159
  readyCheck: { type: "http", port: 5000, path: "/status", timeoutSecs: 90 },
2246
2160
  };
2247
2161
  }
@@ -2379,6 +2293,21 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2379
2293
  parts.functions = functionsDef;
2380
2294
  }
2381
2295
 
2296
+ if (migrationsPath !== null) {
2297
+ parts.migrations = {
2298
+ // Reuse an already required image for this lightweight setup barrier.
2299
+ image: dbDef.image,
2300
+ command: "exec sleep infinity",
2301
+ dependsOn: [
2302
+ "db",
2303
+ ...(withAuth ? ["auth"] : []),
2304
+ ...(withStorage ? ["storage"] : []),
2305
+ ...(withRealtime ? ["realtime"] : []),
2306
+ ],
2307
+ setup: (ctx) => applyMigrations(ctx, migrationsPath, db),
2308
+ } satisfies ServiceDefinition;
2309
+ }
2310
+
2382
2311
  // ── kong (gateway / primary) — no explicit dependsOn: the group
2383
2312
  // expansion makes the primary depend on every member ──────────────
2384
2313
  parts.gateway = {
@@ -2414,15 +2343,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2414
2343
 
2415
2344
  return parts;
2416
2345
  },
2417
- // The project's own SQL, applied with the whole stack up (the group
2418
- // hook chains after the primary, and the primary is the group's
2419
- // dependency sink). Still inside bring-up, so the result is captured
2420
- // into the cached environment like everything else.
2421
- setup: async (ctx) => {
2422
- if (migrationsPath === null) return;
2423
- await applyMigrations(ctx, migrationsPath, dbKey);
2424
- await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
2425
- },
2426
2346
  // The group's consolidated handle at `ctx.svc.<name>`.
2427
2347
  helpers: (): SupabaseHelpers => ({
2428
2348
  sql: new SQL(adminDbUrl, { label: name }),
package/src/daemon.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  // forking the sandbox; inside a single daemon we never run two tests at
17
17
  // once — that keeps stdout capture and timeouts simple.
18
18
 
19
+ import { preparedImages, type PreparedImages } from "./harness/prepared-images.js";
19
20
  import { execFile, spawn } from "node:child_process";
20
21
  import type { ChildProcess } from "node:child_process";
21
- import { randomUUID } from "node:crypto";
22
+ import { createHash, randomUUID } from "node:crypto";
22
23
  import { existsSync, promises as fs, readFileSync } from "node:fs";
23
24
  import net from "node:net";
24
25
  import path from "node:path";
@@ -3512,23 +3513,34 @@ function logBootstrapTimings(t: BootstrapTimings): void {
3512
3513
  console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
3513
3514
  }
3514
3515
 
3515
- async function bootstrap(): Promise<BootstrapTimings> {
3516
+ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
3516
3517
  // Tee everything this bootstrap logs — ours and every service `setup`
3517
3518
  // hook's — into the boot log, so GET /boot-log can explain a slow or
3518
3519
  // finished boot. Restored in the finally at the end.
3519
3520
  const restoreBootLog = captureConsole(BOOT_LOG_SINK);
3520
3521
  try {
3521
- return await bootstrapInner();
3522
+ return await bootstrapInner(prepared);
3522
3523
  } finally {
3523
3524
  restoreBootLog();
3524
3525
  }
3525
3526
  }
3526
3527
 
3527
- async function bootstrapInner(): Promise<BootstrapTimings> {
3528
+ async function imageInput(svc: NamedService): Promise<string> {
3529
+ const input = svc.image.type === "registry" ? svc.image : await resolveDockerfileBuild(svc);
3530
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex");
3531
+ }
3532
+
3533
+ async function bootstrapInner(prepared?: PreparedImages): Promise<BootstrapTimings>;
3534
+ async function bootstrapInner(prepared: undefined, identity: string): Promise<PreparedImages>;
3535
+ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Promise<BootstrapTimings | PreparedImages> {
3528
3536
  const bootStart = Date.now();
3529
3537
  const cfg: EnvironmentConfig = requireLoaded().project.environment;
3530
3538
  const services = namedServices(cfg);
3531
3539
  const timings = new Map<string, ServiceTiming>();
3540
+ if (prepared && (prepared.version !== 1 || !prepared.images ||
3541
+ Object.keys(prepared.images).sort().join("\n") !== services.map(s => s.name).sort().join("\n"))) {
3542
+ throw new Error("prepared image manifest does not match the environment's services");
3543
+ }
3532
3544
  progressInit(services);
3533
3545
  // Build dedup is only valid within one workspace generation — a fresh
3534
3546
  // bootstrap may follow a workspace re-upload with the same dockerfile
@@ -3579,12 +3591,24 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3579
3591
  const tags = new Map<string, string>();
3580
3592
  // Bootstrap is the only dedup scope: all its builds share one
3581
3593
  // /workspace generation (see prepareServiceImage).
3582
- const prepImage = (svc: NamedService): Promise<{ tag: string; buildSteps?: BuildStep[] }> =>
3583
- prepareServiceImage(svc, { dedup: true });
3594
+ const prepImage = async (svc: NamedService): Promise<{ tag: string; buildSteps?: BuildStep[] }> => {
3595
+ if (!prepared) return prepareServiceImage(svc, { dedup: true });
3596
+ const entry = prepared.images[svc.name];
3597
+ if (!entry || entry.input !== await imageInput(svc) || !/^sha256:[a-f0-9]{64}$/.test(entry.image)) {
3598
+ throw new Error(`prepared image inputs changed for ${svc.name}`);
3599
+ }
3600
+ const found = await docker(["image", "inspect", "--format", "{{.Id}}", entry.image], 30_000);
3601
+ if (found.code !== 0 || found.stdout.trim() !== entry.image) {
3602
+ throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
3603
+ }
3604
+ return { tag: entry.image, buildSteps: entry.buildSteps };
3605
+ };
3584
3606
  const prep = new Map<string, Promise<void>>();
3607
+ const prepOffsets = new Map<string, number>();
3585
3608
  for (const svc of services) {
3586
3609
  const p = (async () => {
3587
3610
  const t0 = Date.now();
3611
+ prepOffsets.set(svc.name, Date.now() - bootStart);
3588
3612
  const { tag, buildSteps } = await prepImage(svc);
3589
3613
  progressService(svc.name, { status: "prepared", detail: undefined });
3590
3614
  tags.set(svc.name, tag);
@@ -3603,6 +3627,22 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3603
3627
  prep.set(svc.name, p);
3604
3628
  }
3605
3629
 
3630
+ if (identity !== undefined) {
3631
+ // No application services, setup hooks, ingress, or runtime state in the builder.
3632
+ await Promise.all(prep.values());
3633
+ const images: PreparedImages["images"] = Object.create(null);
3634
+ for (const svc of services) {
3635
+ const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
3636
+ const image = out.stdout.trim();
3637
+ if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
3638
+ throw new Error(`cannot resolve prepared image for ${svc.name}: ${out.stderr}`);
3639
+ }
3640
+ const ti = timings.get(svc.name)!;
3641
+ images[svc.name] = { input: await imageInput(svc), image, prepMs: ti.prepMs, startMs: prepOffsets.get(svc.name), buildSteps: ti.buildSteps };
3642
+ }
3643
+ return { version: 1, identity, images };
3644
+ }
3645
+
3606
3646
  // Ingress (fakes + service-tls proxies) comes up BEFORE services so
3607
3647
  // that any service that calls a fake URL during its own startup
3608
3648
  // probe finds it answering. Service-tls proxies will return 502
@@ -4159,8 +4199,13 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
4159
4199
  },
4160
4200
  projectFiles: () => walkProjectFiles(WORKSPACE),
4161
4201
  async readProjectFile(p: string) {
4202
+ // Not recorded as a read: an inventory fingerprinting every
4203
+ // manifest is bookkeeping, not the suite depending on the files.
4204
+ // Recording them made the setup set name every package.json as a
4205
+ // whole file, so a version bump was "ran during environment
4206
+ // setup" — a full run, the exact case the inventory exists for.
4162
4207
  try {
4163
- return await fs.readFile(resolveProjectPath(p), "utf8");
4208
+ return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8");
4164
4209
  } catch {
4165
4210
  return null;
4166
4211
  }
@@ -4189,9 +4234,21 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
4189
4234
  * The project's reach (`harness/reach.ts`): every project file some
4190
4235
  * service's build context includes, some volume mounts, or a build
4191
4236
  * reads. Computed from the same resolution the builds use, once at the
4192
- * bring-up capture.
4237
+ * bring-up capture. The resolution's own path lookups are bookkeeping,
4238
+ * not reads: they are taken out of the read set again afterwards.
4193
4239
  */
4194
4240
  async function computeProjectReach(all: NamedService[]): Promise<string[]> {
4241
+ const before = takeProjectReads();
4242
+ try {
4243
+ return await computeProjectReachInner(all);
4244
+ } finally {
4245
+ // Discard what the resolution recorded, restore what was there.
4246
+ takeProjectReads();
4247
+ for (const p of before) resolveProjectPath(p);
4248
+ }
4249
+ }
4250
+
4251
+ async function computeProjectReachInner(all: NamedService[]): Promise<string[]> {
4195
4252
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
4196
4253
  const sources: ReachSource[] = [];
4197
4254
  for (const svc of all) {
@@ -6484,7 +6541,7 @@ function loadedSummary(l: ReturnType<typeof requireLoaded>) {
6484
6541
  */
6485
6542
  export function harnessMethods(state: RouteState): MethodTable {
6486
6543
  return {
6487
- health: async () => ({ ok: true }),
6544
+ health: async () => ({ ok: true, capabilities: ["prepared-images-v1"] }),
6488
6545
 
6489
6546
  // Live bootstrap progress, polled by the control plane during
6490
6547
  // `bootstrap` and streamed into the test-run row. `{}` before the
@@ -6593,11 +6650,20 @@ export function harnessMethods(state: RouteState): MethodTable {
6593
6650
  return { refs: [...refs] };
6594
6651
  },
6595
6652
 
6596
- bootstrap: async () =>
6597
- exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => ({
6598
- ok: true,
6599
- timings: await bootstrap(),
6600
- })),
6653
+ prepareImages: async (params) =>
6654
+ exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6655
+ const restore = captureConsole(BOOT_LOG_SINK);
6656
+ try { return await bootstrapInner(undefined, requireString(params, "identity")); }
6657
+ finally { restore(); }
6658
+ }),
6659
+
6660
+ bootstrap: async (params) =>
6661
+ exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
6662
+ const prepared = params.prepared === undefined ? undefined : preparedImages(
6663
+ params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
6664
+ );
6665
+ return { ok: true, timings: await bootstrap(prepared) };
6666
+ }),
6601
6667
 
6602
6668
  projectSetup: async () =>
6603
6669
  exclusive(
@@ -0,0 +1,22 @@
1
+ import { expect, test } from "bun:test";
2
+ import { preparedImages } from "./prepared-images";
3
+
4
+ const entry = { input: "a".repeat(64), image: `sha256:${"b".repeat(64)}`, prepMs: 12 };
5
+ const manifest = () => ({ version: 1, identity: "source-and-sdk", images: { api: entry } });
6
+
7
+ test("handoff pins immutable images and rejects a result from another source or SDK", () => {
8
+ expect(preparedImages(manifest(), "source-and-sdk", ["api"]).images.api.image).toBe(entry.image);
9
+ expect(() => preparedImages(manifest(), "different-source", ["api"])).toThrow(/identity/);
10
+ });
11
+
12
+ test("a missing or added service cannot silently fall back to an in-runtime build", () => {
13
+ expect(() => preparedImages(manifest(), "source-and-sdk", ["api", "worker"])).toThrow(/services/);
14
+ expect(() => preparedImages(manifest(), "source-and-sdk", [])).toThrow(/services/);
15
+ });
16
+
17
+ test("mutable image tags and malformed manifests are rejected", () => {
18
+ const m = manifest();
19
+ m.images.api = { ...entry, image: "spectest/api:latest" };
20
+ expect(() => preparedImages(m, "source-and-sdk", ["api"])).toThrow(/invalid/);
21
+ expect(() => preparedImages(null, "source-and-sdk", ["api"])).toThrow();
22
+ });
@@ -0,0 +1,26 @@
1
+ import type { BuildStep } from "./buildkit-progress.js";
2
+
3
+ export interface PreparedImages {
4
+ version: 1;
5
+ identity: string;
6
+ images: Record<string, { input: string; image: string; prepMs: number; startMs?: number; buildSteps?: BuildStep[] }>;
7
+ }
8
+
9
+ /** Build outputs are required inputs, never a hint to rebuild or pull again. */
10
+ export function preparedImages(value: unknown, identity: unknown, names: string[]): PreparedImages {
11
+ const manifest = value as PreparedImages | null;
12
+ if (!manifest || manifest.version !== 1 || typeof identity !== "string" || !identity || manifest.identity !== identity) {
13
+ throw new Error("prepared image source/SDK identity mismatch");
14
+ }
15
+ if (!manifest.images || typeof manifest.images !== "object" ||
16
+ Object.keys(manifest.images).sort().join("\n") !== [...names].sort().join("\n")) {
17
+ throw new Error("prepared image manifest does not match the environment's services");
18
+ }
19
+ for (const name of names) {
20
+ const entry = manifest.images[name];
21
+ if (!entry || !/^sha256:[a-f0-9]{64}$/.test(entry.image) || !/^[a-f0-9]{64}$/.test(entry.input)) {
22
+ throw new Error(`invalid prepared image for ${name}`);
23
+ }
24
+ }
25
+ return manifest;
26
+ }
@@ -80,10 +80,14 @@ function relative(p: string): string {
80
80
  * the caller's own `fs` error names a real path — `readProjectFile` keeps
81
81
  * reporting ENOENT the way it always has.
82
82
  */
83
- export function resolveProjectPath(p: string): string {
83
+ export function resolveProjectPath(p: string, opts: { record?: boolean } = {}): string {
84
84
  if (path.isAbsolute(p)) return p;
85
85
  const rel = relative(p);
86
- PROJECT_READS.add(rel);
86
+ // A read is coverage of the file for the case that made it — unless
87
+ // the caller says otherwise: the harness's own bookkeeping reads (a
88
+ // coverage inventory fingerprinting every manifest) are not the
89
+ // suite's dependence on those files.
90
+ if (opts.record !== false) PROJECT_READS.add(rel);
87
91
  if (rel.startsWith("spectest/")) {
88
92
  const app = path.join(APP_DIR, rel);
89
93
  if (existsSync(app)) return app;