@specific.dev/spectest 0.86.1 → 0.87.0
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.
- package/dist/components/supabase.d.ts +1 -1
- package/dist/components/supabase.js +61 -108
- package/dist/daemon.js +45 -15
- package/dist/harness/coverage.d.ts +9 -3
- package/dist/harness/coverage.js +21 -3
- package/dist/harness/reach.d.ts +29 -2
- package/dist/harness/reach.js +110 -8
- package/dist/harness/wrapper-rules.d.ts +13 -0
- package/dist/harness/wrapper-rules.js +37 -0
- package/package.json +1 -1
- package/src/components/supabase-dir.test.ts +1 -1
- package/src/components/supabase-migrations.test.ts +116 -0
- package/src/components/supabase.ts +68 -148
- package/src/daemon.ts +47 -15
- package/src/harness/coverage.ts +20 -3
- package/src/harness/reach.test.ts +67 -1
- package/src/harness/reach.ts +113 -8
- package/src/harness/wrapper-rules.test.ts +26 -0
- package/src/harness/wrapper-rules.ts +39 -0
|
@@ -55,6 +55,43 @@
|
|
|
55
55
|
// function over a string, which is what the tests below drive. A user type
|
|
56
56
|
// that happens to be called `Carrier<T>` would also be flagged — and would
|
|
57
57
|
// also be an object at runtime, so the finding stays true.
|
|
58
|
+
/**
|
|
59
|
+
* A test that opens a project file through `node:fs` (or `Bun.file`)
|
|
60
|
+
* instead of `ctx.readProjectFile`. **Advisory.** The subset selector
|
|
61
|
+
* learns which project files a test depends on from the reads the SDK
|
|
62
|
+
* serves (`project-files.ts`); a raw read is a dependence it never sees,
|
|
63
|
+
* so a change to that file can skip the test. The rule is textual — an
|
|
64
|
+
* import or `require` of the fs modules, or a `Bun.file(` call — and is
|
|
65
|
+
* applied to the test tree only, where the alternative exists. It turns
|
|
66
|
+
* blocking when the reach gate enforces (`SUBSET_RUNS.md`).
|
|
67
|
+
*/
|
|
68
|
+
export const CODE_RAW_FS = "SPECTEST2003";
|
|
69
|
+
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
|
|
70
|
+
/** The raw-fs findings of one test file, from its text alone. */
|
|
71
|
+
export function fsUsageDiagnostics(file, text) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const push = (pos, what) => {
|
|
74
|
+
const { line, column } = lineColumnAt(text, pos);
|
|
75
|
+
out.push({
|
|
76
|
+
file,
|
|
77
|
+
line,
|
|
78
|
+
column,
|
|
79
|
+
code: CODE_RAW_FS,
|
|
80
|
+
message: `${what} reads project files behind the SDK's back; use ctx.readProjectFile (or ctx.readProjectFileBytes) ` +
|
|
81
|
+
"so the subset selector knows the test depends on the file",
|
|
82
|
+
blocking: false,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const importRe = /\b(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\brequire\(\s*['"]([^'"]+)['"]\s*\)|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
86
|
+
for (const m of text.matchAll(importRe)) {
|
|
87
|
+
const spec = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
|
|
88
|
+
if (FS_MODULES.has(spec))
|
|
89
|
+
push(m.index ?? 0, `an import of "${spec}"`);
|
|
90
|
+
}
|
|
91
|
+
for (const m of text.matchAll(/\bBun\.file\(/g))
|
|
92
|
+
push(m.index ?? 0, "Bun.file()");
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
58
95
|
/**
|
|
59
96
|
* A condition on a wrapped value — always true. **Blocking**, but on different
|
|
60
97
|
* grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
|
package/package.json
CHANGED
|
@@ -113,7 +113,7 @@ describe("supabase({ dir })", () => {
|
|
|
113
113
|
});
|
|
114
114
|
expect(parts.functions).toBeDefined();
|
|
115
115
|
expect(parts.templates).toBeDefined();
|
|
116
|
-
expect(
|
|
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
|
|
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
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
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
|
-
/**
|
|
1547
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
-
|
|
1606
|
+
command.push("-f", path);
|
|
1704
1607
|
}
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
readyCheck: { type: "
|
|
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
|
-
|
|
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
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
walkProjectFiles,
|
|
49
49
|
ENVIRONMENT_SERVICE,
|
|
50
50
|
REACH_REPORT,
|
|
51
|
+
READS_REPORT,
|
|
51
52
|
type CoverageBundleRef,
|
|
52
53
|
type CoverageReport,
|
|
53
54
|
type ServiceCoverageCapture,
|
|
@@ -97,11 +98,11 @@ import {
|
|
|
97
98
|
sanitizeSegment,
|
|
98
99
|
} from "./harness/volume-paths.js";
|
|
99
100
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
100
|
-
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
101
|
+
import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
|
|
101
102
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
102
103
|
import { cpus, tmpdir } from "node:os";
|
|
103
104
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
104
|
-
import { projectReach,
|
|
105
|
+
import { compactReach, projectReach, renderReachRules, renderReadsLcov, type ReachRules, type ReachSource } from "./harness/reach.js";
|
|
105
106
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
106
107
|
import {
|
|
107
108
|
bindInstrumentationScope,
|
|
@@ -4237,7 +4238,7 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
|
|
|
4237
4238
|
* bring-up capture. The resolution's own path lookups are bookkeeping,
|
|
4238
4239
|
* not reads: they are taken out of the read set again afterwards.
|
|
4239
4240
|
*/
|
|
4240
|
-
async function computeProjectReach(all: NamedService[]): Promise<
|
|
4241
|
+
async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
|
|
4241
4242
|
const before = takeProjectReads();
|
|
4242
4243
|
try {
|
|
4243
4244
|
return await computeProjectReachInner(all);
|
|
@@ -4248,11 +4249,12 @@ async function computeProjectReach(all: NamedService[]): Promise<string[]> {
|
|
|
4248
4249
|
}
|
|
4249
4250
|
}
|
|
4250
4251
|
|
|
4251
|
-
async function computeProjectReachInner(all: NamedService[]): Promise<
|
|
4252
|
+
async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
|
|
4252
4253
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
4253
4254
|
const sources: ReachSource[] = [];
|
|
4254
4255
|
for (const svc of all) {
|
|
4255
4256
|
const src: ReachSource = {
|
|
4257
|
+
service: svc.name,
|
|
4256
4258
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
|
|
4257
4259
|
};
|
|
4258
4260
|
if (svc.image.type === "dockerfile") {
|
|
@@ -4270,29 +4272,35 @@ async function computeProjectReachInner(all: NamedService[]): Promise<string[]>
|
|
|
4270
4272
|
}
|
|
4271
4273
|
sources.push(src);
|
|
4272
4274
|
}
|
|
4273
|
-
|
|
4275
|
+
const reach = projectReach(files, sources);
|
|
4276
|
+
return { version: 1, sources, prefixes: compactReach(files, reach) };
|
|
4274
4277
|
}
|
|
4275
4278
|
|
|
4276
4279
|
/**
|
|
4277
4280
|
* The synthetic `__environment__` capture: what the environment itself
|
|
4278
|
-
* knows — the project's reach (bring-up only) and the project
|
|
4279
|
-
* harness read through the SDK since the previous capture, as
|
|
4280
|
-
* lcov records. Present
|
|
4281
|
-
* the
|
|
4281
|
+
* knows — the project's reach as rules (bring-up only) and the project
|
|
4282
|
+
* files the harness read through the SDK since the previous capture, as
|
|
4283
|
+
* whole lcov records. Present on every capture of every project, coverage
|
|
4284
|
+
* adapters or not: the reach is a property of the environment config,
|
|
4285
|
+
* and it is what lets the control plane tell that a change to `site/**`
|
|
4286
|
+
* can affect no test at all. When services opted in, the two ride the
|
|
4287
|
+
* same bundle as their reports.
|
|
4282
4288
|
*/
|
|
4283
4289
|
async function captureEnvironmentCoverage(all: NamedService[], baseline: boolean): Promise<ServiceCoverageCapture> {
|
|
4284
4290
|
const reports: CoverageReport[] = [];
|
|
4285
4291
|
if (baseline) {
|
|
4286
4292
|
try {
|
|
4287
|
-
const
|
|
4288
|
-
reports.push({ name: REACH_REPORT, content:
|
|
4289
|
-
console.log(
|
|
4293
|
+
const rules = await computeProjectReach(all);
|
|
4294
|
+
reports.push({ name: REACH_REPORT, content: renderReachRules(rules) });
|
|
4295
|
+
console.log(
|
|
4296
|
+
`[coverage] project reach: ${rules.sources.length} source(s), ${rules.prefixes.length} prefix(es) some build or volume can carry`,
|
|
4297
|
+
);
|
|
4290
4298
|
} catch (err) {
|
|
4291
4299
|
console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4292
4300
|
}
|
|
4293
4301
|
}
|
|
4294
4302
|
const reads = takeProjectReads();
|
|
4295
|
-
reports.push({ name:
|
|
4303
|
+
reports.push({ name: READS_REPORT, content: renderReadsLcov(reads) });
|
|
4296
4304
|
return { service: ENVIRONMENT_SERVICE, reports };
|
|
4297
4305
|
}
|
|
4298
4306
|
|
|
@@ -4304,7 +4312,8 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
4304
4312
|
for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
|
|
4305
4313
|
const all = [...byName.values()];
|
|
4306
4314
|
const services = all.filter((s) => s.coverage !== undefined);
|
|
4307
|
-
|
|
4315
|
+
// The environment's own capture is unconditional (see above); the
|
|
4316
|
+
// services' come from the ones that opted in, none included.
|
|
4308
4317
|
const environment = captureEnvironmentCoverage(all, baseline);
|
|
4309
4318
|
const captures = await Promise.all(
|
|
4310
4319
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
@@ -6415,7 +6424,7 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6415
6424
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
6416
6425
|
}
|
|
6417
6426
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
6418
|
-
const wrapper = await runWrapperRulesReport(config);
|
|
6427
|
+
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
6419
6428
|
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
6420
6429
|
file,
|
|
6421
6430
|
line,
|
|
@@ -6453,6 +6462,29 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6453
6462
|
};
|
|
6454
6463
|
}
|
|
6455
6464
|
|
|
6465
|
+
/**
|
|
6466
|
+
* The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
|
|
6467
|
+
* that opens a project file through `node:fs` hides a dependence from the
|
|
6468
|
+
* subset selector. Textual, so it needs no compiler; best-effort.
|
|
6469
|
+
*/
|
|
6470
|
+
async function rawFsReport(): Promise<WrapperDiagnostic[]> {
|
|
6471
|
+
const root = path.join(APP_DIR, "tests");
|
|
6472
|
+
if (!existsSync(root)) return [];
|
|
6473
|
+
try {
|
|
6474
|
+
const files = await walkProjectFiles(root, ["node_modules"]);
|
|
6475
|
+
const out: WrapperDiagnostic[] = [];
|
|
6476
|
+
for (const rel of files) {
|
|
6477
|
+
if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel)) continue;
|
|
6478
|
+
const text = await fs.readFile(path.join(root, rel), "utf8");
|
|
6479
|
+
out.push(...fsUsageDiagnostics(path.join("tests", rel), text));
|
|
6480
|
+
}
|
|
6481
|
+
return out;
|
|
6482
|
+
} catch (err) {
|
|
6483
|
+
console.warn(`[typecheck] raw fs rule threw: ${(err as Error)?.message ?? err}`);
|
|
6484
|
+
return [];
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
6487
|
+
|
|
6456
6488
|
/**
|
|
6457
6489
|
* The blocking rules, run against the BAKED compiler whatever the project
|
|
6458
6490
|
* pins. Best-effort in every direction: an install that predates the API, a
|