@specific.dev/spectest 0.83.0 → 0.85.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/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/postgres-tls.d.ts +10 -0
- package/dist/components/postgres-tls.js +9 -0
- package/dist/components/postgres.d.ts +59 -1
- package/dist/components/postgres.js +5 -2
- package/dist/components/supabase-config.d.ts +5 -0
- package/dist/components/supabase-config.js +12 -4
- package/dist/components/supabase-webhooks.d.ts +1 -0
- package/dist/components/supabase-webhooks.js +262 -0
- package/dist/components/supabase.d.ts +36 -11
- package/dist/components/supabase.js +75 -18
- package/dist/components/temporal.d.ts +89 -0
- package/dist/components/temporal.js +185 -0
- package/dist/coverage.d.ts +72 -0
- package/dist/coverage.js +226 -6
- package/dist/daemon.js +71 -5
- package/dist/environment-ca.d.ts +8 -0
- package/dist/environment-ca.js +16 -0
- package/dist/harness/container-run.js +3 -0
- package/dist/harness/coverage.d.ts +15 -4
- package/dist/harness/coverage.js +48 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/components/index.ts +3 -0
- package/src/components/postgres-tls.test.ts +23 -0
- package/src/components/postgres-tls.ts +11 -0
- package/src/components/postgres.ts +8 -2
- package/src/components/supabase-config.ts +11 -2
- package/src/components/supabase-db.test.ts +64 -0
- package/src/components/supabase-dir.test.ts +71 -0
- package/src/components/supabase-webhooks.ts +263 -0
- package/src/components/supabase.ts +108 -28
- package/src/components/temporal.test.ts +86 -0
- package/src/components/temporal.ts +188 -0
- package/src/coverage.test.ts +72 -2
- package/src/coverage.ts +270 -6
- package/src/daemon.ts +68 -3
- package/src/environment-ca.test.ts +44 -0
- package/src/environment-ca.ts +20 -0
- package/src/harness/container-run.test.ts +14 -2
- package/src/harness/container-run.ts +3 -0
- package/src/harness/coverage.test.ts +32 -1
- package/src/harness/coverage.ts +43 -6
- package/src/index.ts +1 -0
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// supabase: sb.group, // expands to supabase, supabase-db, supabase-auth, ...
|
|
28
28
|
// app: {
|
|
29
29
|
// image: { type: "dockerfile", content: "..." },
|
|
30
|
-
// env: {
|
|
30
|
+
// env: { SUPABASE_URL: sb.url, SUPABASE_ANON_KEY: sb.anonKey, DATABASE_URL: sb.dbUrl },
|
|
31
31
|
// dependsOn: [sb.ready], // wait for the gateway (and the stack behind it)
|
|
32
32
|
// },
|
|
33
33
|
// },
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
// the group naming context, so two Supabase stacks (or a user service
|
|
48
48
|
// innocently named `db`) never collide. The group must be mounted at the key
|
|
49
49
|
// matching the `name` option (default `"supabase"`) since the derived surface
|
|
50
|
-
// (`sb.url`, `sb.
|
|
50
|
+
// (`sb.url`, `sb.dbUrl`) embeds it — a mismatch errors at load.
|
|
51
51
|
|
|
52
52
|
import { createHmac } from "node:crypto";
|
|
53
53
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -69,6 +69,8 @@ import { email, emailHelpers, type EmailHelpers } from "./email.js";
|
|
|
69
69
|
import { github } from "./github.js";
|
|
70
70
|
import { google } from "./google.js";
|
|
71
71
|
import { microsoft } from "./microsoft.js";
|
|
72
|
+
import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
|
|
73
|
+
import { postgresTls } from "./postgres-tls.js";
|
|
72
74
|
import {
|
|
73
75
|
mapSupabaseConfig,
|
|
74
76
|
parseSupabaseConfigFile,
|
|
@@ -99,6 +101,17 @@ const IMAGES = {
|
|
|
99
101
|
templates: "busybox:1.37",
|
|
100
102
|
} as const;
|
|
101
103
|
|
|
104
|
+
/** Override tables without mutating the parsed project config. */
|
|
105
|
+
function mergeConfig(base: unknown, overrides: Record<string, unknown>): Record<string, unknown> {
|
|
106
|
+
const table = (v: unknown): v is Record<string, unknown> =>
|
|
107
|
+
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
108
|
+
const result: Record<string, unknown> = Object.assign(Object.create(null), table(base) ? base : {});
|
|
109
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
110
|
+
result[key] = table(value) ? mergeConfig(result[key], value) : value;
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
/**
|
|
103
116
|
* Social sign-in providers this stack can serve, by the name GoTrue (and
|
|
104
117
|
* `[auth.external.<name>]`) uses. Each factory builds one container answering
|
|
@@ -204,6 +217,22 @@ function absProjectPath(p: string): string {
|
|
|
204
217
|
}
|
|
205
218
|
|
|
206
219
|
export interface SupabaseOptions {
|
|
220
|
+
/** Database image and extensions installed before project migrations. */
|
|
221
|
+
db?: {
|
|
222
|
+
/** Native database TLS, enabled by default using the environment CA. */
|
|
223
|
+
tls?: boolean;
|
|
224
|
+
/** Full Supabase-compatible Postgres image reference. Defaults to the pinned SDK image. */
|
|
225
|
+
image?: string;
|
|
226
|
+
/**
|
|
227
|
+
* Explicit extension overrides for projects whose migrations pin versions
|
|
228
|
+
* absent from the image. Omit version to use the image's default, or use
|
|
229
|
+
* db.image to supply an image containing the exact required version.
|
|
230
|
+
* Existing extensions must match the requested schema/version.
|
|
231
|
+
* Example: `{ vector: { schema: "public" } }` opts into the image's vector
|
|
232
|
+
* version before a historical CREATE EXTENSION IF NOT EXISTS migration.
|
|
233
|
+
*/
|
|
234
|
+
extensions?: Record<string, { schema?: string; version?: string }>;
|
|
235
|
+
};
|
|
207
236
|
/**
|
|
208
237
|
* Key of the API-gateway service and the prefix for every other piece.
|
|
209
238
|
* Default `"supabase"`. The gateway is reachable at `http://<name>:8000`
|
|
@@ -239,7 +268,7 @@ export interface SupabaseOptions {
|
|
|
239
268
|
/**
|
|
240
269
|
* Also serve the gateway over **HTTPS** at `https://<hostname>` via the
|
|
241
270
|
* daemon's CA-trusted TLS reverse proxy (in addition to the plain
|
|
242
|
-
* `http://<name>:8000`). `sb.url`
|
|
271
|
+
* `http://<name>:8000`). `sb.url` then points at the HTTPS
|
|
243
272
|
* address. Use TLS wherever you can: set this when a browser reaches the
|
|
244
273
|
* gateway, because a page that is open over `https://` cannot call an
|
|
245
274
|
* `http://` origin (Chromium blocks the request before it leaves the
|
|
@@ -298,14 +327,21 @@ export interface SupabaseOptions {
|
|
|
298
327
|
* written one. Nothing is derived unless you name the folder, and each piece
|
|
299
328
|
* is optional inside it — a folder with only a `config.toml` is fine.
|
|
300
329
|
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* where your project already declares them and where the Supabase CLI reads
|
|
304
|
-
* them too. One source of truth, and the environment matches the one you run
|
|
305
|
-
* locally.
|
|
330
|
+
* Use `configOverrides` for environment-specific config values without
|
|
331
|
+
* moving this folder's migrations, seed, templates or functions.
|
|
306
332
|
*/
|
|
307
333
|
dir?: string;
|
|
308
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Values recursively merged into the project's parsed Supabase config.
|
|
337
|
+
* Tables merge; arrays and scalar values replace their originals. Uses the
|
|
338
|
+
* same nested keys as config.toml, e.g.:
|
|
339
|
+
* `{ auth: { hook: { before_user_created: { uri: "https://app.test/hook" } } } }`.
|
|
340
|
+
* `env(...)` in overrides resolves through `env` just as it does in the file.
|
|
341
|
+
* Also works without `dir` for a stack with no project SQL or functions.
|
|
342
|
+
*/
|
|
343
|
+
configOverrides?: Record<string, unknown>;
|
|
344
|
+
|
|
309
345
|
/**
|
|
310
346
|
* The `.env` of this environment — the values your `config.toml` reads with
|
|
311
347
|
* `env(...)`.
|
|
@@ -484,9 +520,6 @@ export interface SupabaseStack {
|
|
|
484
520
|
* share the one mailbox: `env: { SMTP_HOST: sb.smtp.host, SMTP_PORT: String(sb.smtp.port) }`.
|
|
485
521
|
*/
|
|
486
522
|
smtp?: { host: string; port: number };
|
|
487
|
-
/** Ready-to-spread env for the app under test: `SUPABASE_URL`,
|
|
488
|
-
* `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`. */
|
|
489
|
-
appEnv: Record<string, string>;
|
|
490
523
|
}
|
|
491
524
|
|
|
492
525
|
// Supabase's well-known demo secret (≥32 chars). Local-only; override via opts.
|
|
@@ -542,6 +575,31 @@ function pgLiteral(s: string): string {
|
|
|
542
575
|
return `'${s.replace(/'/g, "''")}'`;
|
|
543
576
|
}
|
|
544
577
|
|
|
578
|
+
function pgIdentifier(s: string): string {
|
|
579
|
+
if (!s || s.includes("\0")) throw new Error("supabase(): an SQL identifier must be non-empty and contain no NUL");
|
|
580
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function extensionSql(extensions: NonNullable<SupabaseOptions["db"]>["extensions"]): string {
|
|
584
|
+
return Object.entries(extensions ?? {}).map(([name, { schema, version }]) => {
|
|
585
|
+
const install = `CREATE EXTENSION IF NOT EXISTS ${pgIdentifier(name)}` +
|
|
586
|
+
(schema ? ` SCHEMA ${pgIdentifier(schema)}` : "") +
|
|
587
|
+
(version ? ` VERSION ${pgLiteral(version)}` : "") + ";";
|
|
588
|
+
// IF NOT EXISTS does not validate an already-installed extension.
|
|
589
|
+
const matches = [
|
|
590
|
+
version ? `e.extversion = ${pgLiteral(version)}` : "true",
|
|
591
|
+
schema ? `n.nspname = ${pgLiteral(schema)}` : "true",
|
|
592
|
+
].join(" AND ");
|
|
593
|
+
const check = `BEGIN
|
|
594
|
+
IF EXISTS (SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace
|
|
595
|
+
WHERE e.extname = ${pgLiteral(name)} AND NOT (${matches})) THEN
|
|
596
|
+
RAISE EXCEPTION USING MESSAGE = ${pgLiteral(`supabase: extension ${name} does not match the requested schema/version`)};
|
|
597
|
+
END IF;
|
|
598
|
+
END`;
|
|
599
|
+
return `${install}\nDO ${pgLiteral(check)};`;
|
|
600
|
+
}).join("\n");
|
|
601
|
+
}
|
|
602
|
+
|
|
545
603
|
function buildBootstrapSql(
|
|
546
604
|
dbPassword: string,
|
|
547
605
|
jwtSecret: string,
|
|
@@ -1654,11 +1712,14 @@ async function reloadPostgrestSchema(
|
|
|
1654
1712
|
/**
|
|
1655
1713
|
* A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
|
|
1656
1714
|
* matching the `name` option (default `"supabase"`) and wire the app with
|
|
1657
|
-
*
|
|
1715
|
+
* its connection values and `.ready`:
|
|
1658
1716
|
*
|
|
1659
1717
|
* ```ts
|
|
1660
1718
|
* const sb = supabase({ dbPassword: "postgres" });
|
|
1661
|
-
* services: { supabase: sb.group, app: {
|
|
1719
|
+
* services: { supabase: sb.group, app: {
|
|
1720
|
+
* image, env: { SUPABASE_URL: sb.url, SUPABASE_ANON_KEY: sb.anonKey },
|
|
1721
|
+
* dependsOn: [sb.ready],
|
|
1722
|
+
* } }
|
|
1662
1723
|
* ```
|
|
1663
1724
|
*
|
|
1664
1725
|
* See the file header for the full picture. Tests get an instrumented DB client
|
|
@@ -1722,11 +1783,29 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1722
1783
|
// services map is returned. `config.json` wins where a newer CLI wrote one.
|
|
1723
1784
|
const configPath = inDir("config.json") ?? inDir("config.toml");
|
|
1724
1785
|
let mapped: MappedSupabaseConfig | null = null;
|
|
1725
|
-
if (configPath !== null) {
|
|
1726
|
-
const shown = configPath.startsWith(projectRootPath())
|
|
1786
|
+
if (configPath !== null || opts.configOverrides !== undefined) {
|
|
1787
|
+
const shown = configPath === null ? "configOverrides" : configPath.startsWith(projectRootPath())
|
|
1727
1788
|
? configPath.slice(projectRootPath().length + 1)
|
|
1728
1789
|
: configPath;
|
|
1729
|
-
mapped = mapSupabaseConfig(
|
|
1790
|
+
mapped = mapSupabaseConfig(mergeConfig(
|
|
1791
|
+
configPath === null ? {} : parseSupabaseConfigFile(configPath),
|
|
1792
|
+
opts.configOverrides ?? {},
|
|
1793
|
+
));
|
|
1794
|
+
for (const [target, { from, key }] of Object.entries(mapped.deferredAuth)) {
|
|
1795
|
+
const enabled = target.replace(/_(URI|SECRETS)$/, "_ENABLED");
|
|
1796
|
+
if (!withAuth || mapped.auth[enabled] !== "true") continue;
|
|
1797
|
+
const value = opts.env?.[from];
|
|
1798
|
+
if (value === undefined || value === "") {
|
|
1799
|
+
throw new Error(`supabase(): ${key} reads env(${from}); supply a non-empty test value with env: { ${from}: "…" }.`);
|
|
1800
|
+
}
|
|
1801
|
+
mapped.auth[target] = value;
|
|
1802
|
+
}
|
|
1803
|
+
for (const key of mapped.encryptedValues) {
|
|
1804
|
+
const hook = /^auth\.hook\.([^.]+)\.(uri|secrets)$/.exec(key);
|
|
1805
|
+
if (withAuth && hook && mapped.auth[`GOTRUE_HOOK_${hook[1]!.toUpperCase()}_ENABLED`] === "true") {
|
|
1806
|
+
throw new Error(`supabase(): ${key} is encrypted; override it with env(NAME) and supply the test value through env.`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1730
1809
|
// What the file asked for and did not get. Reported, never fatal: the
|
|
1731
1810
|
// config belongs to the project and describes more than this environment,
|
|
1732
1811
|
// so a key that is meaningless in a hermetic VM is not grounds to refuse
|
|
@@ -1756,6 +1835,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1756
1835
|
// and nothing says why.
|
|
1757
1836
|
const referenced = new Set<string>([
|
|
1758
1837
|
...Object.values(mapped.deferredSecrets),
|
|
1838
|
+
...Object.values(mapped.deferredAuth).map(({ from }) => from),
|
|
1759
1839
|
...Object.values(mapped.externalProviders).flatMap((p) =>
|
|
1760
1840
|
[p.clientIdFrom, p.secretFrom].filter((n): n is string => n !== undefined),
|
|
1761
1841
|
),
|
|
@@ -1837,17 +1917,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1837
1917
|
const anonKey = apiKey("anon", jwtSecret);
|
|
1838
1918
|
const serviceRoleKey = apiKey("service_role", jwtSecret);
|
|
1839
1919
|
|
|
1840
|
-
// Derived
|
|
1920
|
+
// Derived connection values, available before the group expands.
|
|
1841
1921
|
// `expectKey: name` on the group guarantees these names and the expanded
|
|
1842
1922
|
// service keys agree (`g.key("db")` at expansion === `${name}-db` here).
|
|
1843
1923
|
const dbKey = `${name}-db`;
|
|
1844
1924
|
const url = opts.hostname ? `https://${opts.hostname}` : `http://${name}:8000`;
|
|
1845
1925
|
// App-facing URL: the conventional `postgres` role (owns migrated tables).
|
|
1846
|
-
const
|
|
1926
|
+
const dbTls = opts.db?.tls !== false ? postgresTls() : undefined;
|
|
1927
|
+
const dbUrl = `postgresql://postgres:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
|
|
1928
|
+
(dbTls ? "?sslmode=verify-full" : "");
|
|
1847
1929
|
// The test-side `ctx.svc.<name>.sql` helper connects as the superuser
|
|
1848
1930
|
// `supabase_admin` so it can inspect everything — public tables plus the
|
|
1849
1931
|
// `auth`/`storage` schemas — regardless of ownership.
|
|
1850
|
-
const adminDbUrl = `postgresql://supabase_admin:${dbPassword}@${dbKey}:5432/postgres
|
|
1932
|
+
const adminDbUrl = `postgresql://supabase_admin:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
|
|
1933
|
+
(dbTls ? "?sslmode=verify-full" : "");
|
|
1851
1934
|
|
|
1852
1935
|
// ── social sign-in ────────────────────────────────────────────────────
|
|
1853
1936
|
// Every `[auth.external.<name>]` the config enables: served in-environment
|
|
@@ -1900,7 +1983,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1900
1983
|
|
|
1901
1984
|
// ── db ──────────────────────────────────────────────────────────
|
|
1902
1985
|
const dbDef: ServiceDefinition = {
|
|
1903
|
-
image: { type: "registry", reference: IMAGES.db },
|
|
1986
|
+
image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
|
|
1904
1987
|
// `args` (not `command`): keep the image's docker-entrypoint.sh so it
|
|
1905
1988
|
// runs initdb + its baked init-scripts before starting.
|
|
1906
1989
|
args: [
|
|
@@ -1909,7 +1992,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1909
1992
|
"config_file=/etc/postgresql/postgresql.conf",
|
|
1910
1993
|
"-c",
|
|
1911
1994
|
"log_min_messages=fatal",
|
|
1995
|
+
...(dbTls?.args ?? []),
|
|
1912
1996
|
],
|
|
1997
|
+
...(dbTls ? { certificates: dbTls.certificates } : {}),
|
|
1913
1998
|
env: {
|
|
1914
1999
|
POSTGRES_HOST: "/var/run/postgresql",
|
|
1915
2000
|
PGPORT: "5432",
|
|
@@ -1940,6 +2025,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1940
2025
|
// superuser in modern supabase/postgres; `supabase_admin` is.
|
|
1941
2026
|
"supabase_admin",
|
|
1942
2027
|
);
|
|
2028
|
+
await applySqlFile(ctx, SUPABASE_WEBHOOKS_SQL, "database webhooks", "supabase_admin");
|
|
2029
|
+
const extensions = extensionSql(opts.db?.extensions);
|
|
2030
|
+
if (extensions) await applySqlFile(ctx, extensions, "configured extensions", "postgres");
|
|
1943
2031
|
},
|
|
1944
2032
|
};
|
|
1945
2033
|
parts.db = dbDef;
|
|
@@ -2345,13 +2433,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2345
2433
|
}),
|
|
2346
2434
|
});
|
|
2347
2435
|
|
|
2348
|
-
const appEnv = {
|
|
2349
|
-
SUPABASE_URL: url,
|
|
2350
|
-
SUPABASE_ANON_KEY: anonKey,
|
|
2351
|
-
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
2352
|
-
DATABASE_URL: dbUrl,
|
|
2353
|
-
};
|
|
2354
|
-
|
|
2355
2436
|
return {
|
|
2356
2437
|
group,
|
|
2357
2438
|
url,
|
|
@@ -2359,7 +2440,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2359
2440
|
serviceRoleKey,
|
|
2360
2441
|
dbUrl,
|
|
2361
2442
|
ready: name,
|
|
2362
|
-
appEnv,
|
|
2363
2443
|
...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
|
|
2364
2444
|
};
|
|
2365
2445
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineEnvironment } from "../index.js";
|
|
3
|
+
import { temporal, temporalClient } from "./temporal.js";
|
|
4
|
+
|
|
5
|
+
describe("temporal", () => {
|
|
6
|
+
test("uses one prebuilt headless server and gates on the namespace API", () => {
|
|
7
|
+
const t = temporal({ namespace: "specific", namespaces: ["customer"], searchAttributes: { SurveyId: "Keyword" } });
|
|
8
|
+
const env = defineEnvironment({ name: "test", services: { temporal: t.group } });
|
|
9
|
+
expect(Object.keys(env.config.services)).toEqual(["temporal"]);
|
|
10
|
+
const server = env.config.services.temporal;
|
|
11
|
+
expect(server.args).toContain("--headless");
|
|
12
|
+
expect(server.args).toContain("SurveyId=Keyword");
|
|
13
|
+
expect(server.args).toContain("customer");
|
|
14
|
+
expect(server.command).toBeUndefined();
|
|
15
|
+
expect(server.readyCheck).toMatchObject({ type: "http", path: "/api/v1/namespaces/specific" });
|
|
16
|
+
});
|
|
17
|
+
test("TLS has its own certificate and upstream dependency, never HTTP ingress", () => {
|
|
18
|
+
const t = temporal({ name: "cloud", tls: { hostname: "temporal.test" } });
|
|
19
|
+
const env = defineEnvironment({ name: "test", services: { cloud: t.group } });
|
|
20
|
+
expect(Object.keys(env.config.services).sort()).toEqual(["cloud", "cloud-server"]);
|
|
21
|
+
expect(env.config.services.cloud.dependsOn).toContain("cloud-server");
|
|
22
|
+
expect(env.config.services.cloud.certificates?.[0]?.hostnames).toEqual(["temporal.test"]);
|
|
23
|
+
expect(env.config.services.cloud.tls).toBeUndefined();
|
|
24
|
+
expect(t.address).toBe("temporal.test:7233");
|
|
25
|
+
});
|
|
26
|
+
test("rejects invalid ports and search attributes", () => {
|
|
27
|
+
expect(() => temporal({ port: 7243 })).toThrow("distinct");
|
|
28
|
+
expect(() => temporal({ searchAttributes: { "invalid=name": "Keyword" } })).toThrow("search attribute");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("Temporal API helpers", () => {
|
|
33
|
+
test("namespace registration is idempotent on the structured AlreadyExists code", async () => {
|
|
34
|
+
const methods: string[] = [];
|
|
35
|
+
const c = temporalClient("http://temporal", "default", async (_, init) => {
|
|
36
|
+
methods.push(init?.method ?? "GET");
|
|
37
|
+
return init?.method === "POST" ? Response.json({ code: 6, message: "exists" }, { status: 409 }) :
|
|
38
|
+
Response.json({ namespaceInfo: { name: "new", state: "NAMESPACE_STATE_REGISTERED", id: "id" } });
|
|
39
|
+
});
|
|
40
|
+
expect((await c.registerNamespace("new")).namespaceInfo.name).toBe("new");
|
|
41
|
+
expect(methods).toEqual(["POST", "GET"]);
|
|
42
|
+
});
|
|
43
|
+
test("requires both task types and rejects stale or unrelated pollers", async () => {
|
|
44
|
+
const urls: string[] = [];
|
|
45
|
+
const c = temporalClient("http://temporal", "default", async input => {
|
|
46
|
+
urls.push(String(input));
|
|
47
|
+
return Response.json({ pollers: [{ identity: "other", lastAccessTime: new Date().toISOString() },
|
|
48
|
+
{ identity: "expected", lastAccessTime: new Date(Date.now() - 300000).toISOString() }] });
|
|
49
|
+
});
|
|
50
|
+
await expect(c.waitForWorkers("q", { identities: ["expected"], timeoutMs: 20 })).rejects.toThrow("Observed pollers");
|
|
51
|
+
expect(urls.some(u => u.endsWith("taskQueueType=1"))).toBe(true);
|
|
52
|
+
expect(urls.some(u => u.endsWith("taskQueueType=2"))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
test("recognizes a ready activity-only worker", async () => {
|
|
55
|
+
const c = temporalClient("http://temporal", "default", async () => Response.json({ pollers: [
|
|
56
|
+
{ identity: "worker", lastAccessTime: new Date().toISOString() },
|
|
57
|
+
] }));
|
|
58
|
+
expect((await c.waitForWorkers("q", { types: ["activity"], identities: ["worker"] })).activity).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
test("authentication failures fail immediately rather than waiting out the timeout", async () => {
|
|
61
|
+
let calls = 0;
|
|
62
|
+
const c = temporalClient("http://temporal", "default", async () => { calls++; return Response.json({ code: 16, message: "denied" }, { status: 401 }); });
|
|
63
|
+
await expect(c.waitForWorkers("q", { types: ["workflow"] })).rejects.toThrow("HTTP 401");
|
|
64
|
+
expect(calls).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
test("namespace creation recovers from a lost response without changing an existing namespace", async () => {
|
|
67
|
+
let posts = 0;
|
|
68
|
+
const c = temporalClient("http://temporal", "default", async (_, init) => {
|
|
69
|
+
if (init?.method === "POST") {
|
|
70
|
+
if (++posts === 1) throw new TypeError("connection closed after registration");
|
|
71
|
+
return Response.json({ code: 6 }, { status: 409 });
|
|
72
|
+
}
|
|
73
|
+
return Response.json({ namespaceInfo: { name: "customer", state: "NAMESPACE_STATE_REGISTERED", id: "existing" } });
|
|
74
|
+
});
|
|
75
|
+
expect((await c.registerNamespace("customer")).namespaceInfo.id).toBe("existing");
|
|
76
|
+
expect(posts).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
test("polling retries a gateway's non-JSON transient response", async () => {
|
|
79
|
+
let calls = 0;
|
|
80
|
+
const c = temporalClient("http://temporal", "default", async () => ++calls === 1
|
|
81
|
+
? new Response("upstream unavailable", { status: 503 })
|
|
82
|
+
: Response.json({ pollers: [{ identity: "worker", lastAccessTime: new Date().toISOString() }] }));
|
|
83
|
+
expect((await c.waitForWorkers("q", { types: ["activity"] })).activity).toHaveLength(1);
|
|
84
|
+
expect(calls).toBe(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { serviceGroup, type ServiceDefinition, type ServicesMap } from "../index.js";
|
|
2
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
3
|
+
import { readRaw } from "../inspect.js";
|
|
4
|
+
|
|
5
|
+
export type TemporalSearchAttributeType = "Text" | "Keyword" | "Int" | "Double" | "Bool" | "Datetime" | "KeywordList";
|
|
6
|
+
export type TemporalTaskType = "workflow" | "activity";
|
|
7
|
+
export interface TemporalPoller { identity: string; lastAccessTime?: string }
|
|
8
|
+
export interface TemporalWorkerOptions {
|
|
9
|
+
namespace?: string;
|
|
10
|
+
/** Both by default; activity-only workers can select ["activity"]. */
|
|
11
|
+
types?: TemporalTaskType[];
|
|
12
|
+
/** Require every named worker identity on each requested queue type. */
|
|
13
|
+
identities?: string[];
|
|
14
|
+
minPollers?: number;
|
|
15
|
+
maxAgeMs?: number;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface TemporalNamespace {
|
|
19
|
+
namespaceInfo: { name: string; state: string; id: string };
|
|
20
|
+
config?: { workflowExecutionRetentionTtl?: string };
|
|
21
|
+
}
|
|
22
|
+
export interface TemporalOptions {
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Pinned CLI image, including the real Temporal server and SQLite. */
|
|
25
|
+
image?: string;
|
|
26
|
+
namespace?: string;
|
|
27
|
+
namespaces?: string[];
|
|
28
|
+
searchAttributes?: Record<string, TemporalSearchAttributeType>;
|
|
29
|
+
port?: number;
|
|
30
|
+
httpPort?: number;
|
|
31
|
+
/** Off by default; no UI process or assets are needed by workers. */
|
|
32
|
+
ui?: boolean;
|
|
33
|
+
/** Optional native gRPC TLS endpoint; adds a small TLS terminator. */
|
|
34
|
+
tls?: { hostname: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class TemporalApiError extends Error {
|
|
38
|
+
constructor(readonly status: number, readonly code: number | undefined, message: string) { super(message); }
|
|
39
|
+
}
|
|
40
|
+
class TemporalTransportError extends Error {}
|
|
41
|
+
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
const positive = (name: string, n: number) => {
|
|
43
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`temporal(): ${name} must be positive`);
|
|
44
|
+
return n;
|
|
45
|
+
};
|
|
46
|
+
const namespaceName = (name: string) => {
|
|
47
|
+
if (!name || name.includes("\0")) throw new Error("temporal(): namespace must be non-empty and contain no NUL");
|
|
48
|
+
return name;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Stateless HTTP client, also usable inside a fake that provisions real namespaces.
|
|
52
|
+
* No gRPC channel survives a VM restore, and no CLI process is spawned per request.
|
|
53
|
+
*/
|
|
54
|
+
export function temporalClient(httpUrl: string, namespace = "default", fetcher: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = rawFetch) {
|
|
55
|
+
const nsPath = (ns: string) => `/api/v1/namespaces/${encodeURIComponent(namespaceName(ns))}`;
|
|
56
|
+
async function request<T>(path: string, init?: RequestInit, timeoutMs = 5000): Promise<T> {
|
|
57
|
+
let response: Response;
|
|
58
|
+
let body: string;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetcher(`${httpUrl}${path}`, { ...init, signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutMs))) });
|
|
61
|
+
body = await response.text();
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
throw new TemporalTransportError(`Temporal ${path}: ${String(cause)}`, { cause });
|
|
64
|
+
}
|
|
65
|
+
let value: any;
|
|
66
|
+
try { value = body ? JSON.parse(body) : {}; }
|
|
67
|
+
catch {
|
|
68
|
+
if (response.ok) throw new Error(`Temporal ${path}: invalid JSON (HTTP ${response.status}): ${body.slice(0, 500)}`);
|
|
69
|
+
value = {}; // Gateways may return plain text or HTML for a transient error.
|
|
70
|
+
}
|
|
71
|
+
if (!response.ok) throw new TemporalApiError(response.status, value?.code,
|
|
72
|
+
`Temporal ${path}: HTTP ${response.status}: ${value?.message ?? body.slice(0, 500)}`);
|
|
73
|
+
return value as T;
|
|
74
|
+
}
|
|
75
|
+
async function until<T>(description: string, timeoutMs: number, probe: (remaining: number) => Promise<T | null>) {
|
|
76
|
+
const deadline = Date.now() + positive("timeoutMs", timeoutMs);
|
|
77
|
+
let last: unknown = "condition not met";
|
|
78
|
+
let interval = 50;
|
|
79
|
+
do {
|
|
80
|
+
try { const value = await probe(Math.min(5000, Math.max(1, deadline - Date.now()))); if (value !== null) return value; }
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (!(error instanceof TemporalTransportError) &&
|
|
83
|
+
!(error instanceof TemporalApiError && [404, 429, 502, 503, 504].includes(error.status))) throw error;
|
|
84
|
+
last = error;
|
|
85
|
+
}
|
|
86
|
+
const remaining = deadline - Date.now();
|
|
87
|
+
if (remaining <= 0) break;
|
|
88
|
+
await delay(Math.min(interval, remaining));
|
|
89
|
+
interval = Math.min(500, interval * 2);
|
|
90
|
+
} while (Date.now() < deadline);
|
|
91
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}. Last result: ${String(last)}`);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
httpUrl, namespace,
|
|
95
|
+
describeNamespace(name = namespace) { return request<TemporalNamespace>(nsPath(name)); },
|
|
96
|
+
async registerNamespace(name: string, options: { retentionDays?: number; timeoutMs?: number } = {}) {
|
|
97
|
+
namespaceName(name);
|
|
98
|
+
const timeoutMs = positive("timeoutMs", options.timeoutMs ?? 10000);
|
|
99
|
+
const deadline = Date.now() + timeoutMs;
|
|
100
|
+
const retentionSeconds = positive("retentionDays", options.retentionDays ?? 1) * 86400;
|
|
101
|
+
await until(`Temporal namespace ${JSON.stringify(name)} creation`, timeoutMs, async remaining => {
|
|
102
|
+
try {
|
|
103
|
+
await request("/api/v1/namespaces", { method: "POST", headers: { "content-type": "application/json" },
|
|
104
|
+
body: JSON.stringify({ namespace: name, workflowExecutionRetentionPeriod: `${retentionSeconds}s` }) }, remaining);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// A retry after a lost response is safe: names are unique, and an
|
|
107
|
+
// existing namespace is never updated by this operation.
|
|
108
|
+
if (!(error instanceof TemporalApiError && error.code === 6)) throw error;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
return until(`Temporal namespace ${JSON.stringify(name)} to be registered`, Math.max(1, deadline - Date.now()), async remaining => {
|
|
113
|
+
const result = await request<TemporalNamespace>(nsPath(name), undefined, remaining);
|
|
114
|
+
return result.namespaceInfo?.state === "NAMESPACE_STATE_REGISTERED" ? result : null;
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
async waitForWorkers(taskQueue: string, options: TemporalWorkerOptions = {}) {
|
|
118
|
+
if (!taskQueue) throw new Error("temporal(): taskQueue must be non-empty");
|
|
119
|
+
const ns = options.namespace ?? namespace;
|
|
120
|
+
const types = [...new Set(options.types ?? ["workflow", "activity"])];
|
|
121
|
+
if (!types.length || types.some(t => t !== "workflow" && t !== "activity")) throw new Error("temporal(): types must contain workflow and/or activity");
|
|
122
|
+
const minPollers = positive("minPollers", options.minPollers ?? 1);
|
|
123
|
+
if (!Number.isInteger(minPollers)) throw new Error("temporal(): minPollers must be an integer");
|
|
124
|
+
const maxAgeMs = positive("maxAgeMs", options.maxAgeMs ?? 120000);
|
|
125
|
+
let observed: Record<string, string[]> = {};
|
|
126
|
+
try {
|
|
127
|
+
return await until(`Temporal workers on ${ns}/${taskQueue} (${types.join(", ")})`, options.timeoutMs ?? 30000, async remaining => {
|
|
128
|
+
const entries = await Promise.all(types.map(async type => {
|
|
129
|
+
const result = await request<{ pollers?: TemporalPoller[] }>(`${nsPath(ns)}/task-queues/${encodeURIComponent(taskQueue)}?taskQueueType=${type === "workflow" ? 1 : 2}`, undefined, remaining);
|
|
130
|
+
const pollers = (result.pollers ?? []).filter(p => p.identity && p.lastAccessTime && Date.now() - Date.parse(p.lastAccessTime) <= maxAgeMs);
|
|
131
|
+
return [type, pollers] as const;
|
|
132
|
+
}));
|
|
133
|
+
observed = Object.fromEntries(entries.map(([t, ps]) => [t, ps.map(p => p.identity)]));
|
|
134
|
+
return entries.every(([, ps]) => new Set(ps.map(p => p.identity)).size >= minPollers &&
|
|
135
|
+
(options.identities ?? []).every(id => ps.some(p => p.identity === id))) ? Object.fromEntries(entries) : null;
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof Error) error.message += ` Observed pollers: ${JSON.stringify(observed)}`;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export type TemporalClient = ReturnType<typeof temporalClient>;
|
|
145
|
+
|
|
146
|
+
export function temporal(opts: TemporalOptions = {}) {
|
|
147
|
+
const name = opts.name ?? "temporal";
|
|
148
|
+
const namespace = namespaceName(opts.namespace ?? "default");
|
|
149
|
+
const port = opts.port ?? 7233;
|
|
150
|
+
const httpPort = opts.httpPort ?? 7243;
|
|
151
|
+
for (const p of [port, httpPort]) if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("temporal(): ports must be integers between 1 and 65535");
|
|
152
|
+
if (port === httpPort || (opts.ui && [port, httpPort].includes(8233))) throw new Error("temporal(): listener ports must be distinct");
|
|
153
|
+
if (opts.tls && !/^(?:[a-z0-9-]+\.)+[a-z0-9-]+$/.test(opts.tls.hostname)) throw new Error("temporal(): TLS hostname must be a multi-label DNS name");
|
|
154
|
+
const serverName = opts.tls ? `${name}-server` : name;
|
|
155
|
+
const address = `${opts.tls?.hostname ?? name}:${port}`;
|
|
156
|
+
const httpUrl = `http://${serverName}:${httpPort}`;
|
|
157
|
+
const namespaces = [...new Set([namespace, ...(opts.namespaces ?? [])])].map(namespaceName);
|
|
158
|
+
const args = ["server", "start-dev", "--ip", "0.0.0.0", "--port", String(port), "--http-port", String(httpPort), "--log-format", "json"];
|
|
159
|
+
args.push(...(opts.ui ? ["--ui-port", "8233", "--ui-disable-news-fetch"] : ["--headless"]));
|
|
160
|
+
for (const ns of namespaces) if (ns !== "default") args.push("--namespace", ns);
|
|
161
|
+
for (const [key, type] of Object.entries(opts.searchAttributes ?? {})) {
|
|
162
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || !["Text", "Keyword", "Int", "Double", "Bool", "Datetime", "KeywordList"].includes(type)) throw new Error(`temporal(): invalid search attribute ${key}=${type}`);
|
|
163
|
+
args.push("--search-attribute", `${key}=${type}`);
|
|
164
|
+
}
|
|
165
|
+
const primary = opts.tls ? "tls" : "server";
|
|
166
|
+
const group = serviceGroup({
|
|
167
|
+
primary, expectKey: name,
|
|
168
|
+
services: (): ServicesMap => {
|
|
169
|
+
const server: ServiceDefinition = {
|
|
170
|
+
image: { type: "registry", reference: opts.image ?? "temporalio/temporal:1.9.1" }, args,
|
|
171
|
+
ports: [port, httpPort, ...(opts.ui ? [8233] : [])],
|
|
172
|
+
readyCheck: { type: "http", port: httpPort, path: `/api/v1/namespaces/${encodeURIComponent(namespace)}`, timeoutSecs: 30 },
|
|
173
|
+
};
|
|
174
|
+
if (!opts.tls) return { server };
|
|
175
|
+
return { server, tls: {
|
|
176
|
+
image: { type: "registry", reference: "nginx:1.29.6-alpine" },
|
|
177
|
+
ports: [port], hostnames: [opts.tls.hostname], dependsOn: [serverName],
|
|
178
|
+
certificates: [{ hostnames: [opts.tls.hostname], certPath: "/tls/server.crt", keyPath: "/tls/server.key" }],
|
|
179
|
+
files: [{ path: "/etc/nginx/nginx.conf", content: `worker_processes 1;\nevents { worker_connections 1024; }\nstream { server { listen ${port} ssl; ssl_certificate /tls/server.crt; ssl_certificate_key /tls/server.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_alpn h2; proxy_pass ${serverName}:${port}; } }\n` }],
|
|
180
|
+
readyCheck: { type: "tcp", port, timeoutSecs: 30 },
|
|
181
|
+
} };
|
|
182
|
+
},
|
|
183
|
+
helpers: (ctx) => temporalClient(httpUrl, namespace,
|
|
184
|
+
async (input, init) => readRaw(await ctx.fetch(String(input), init)) as unknown as Response),
|
|
185
|
+
});
|
|
186
|
+
return { group, address, httpUrl, namespace, ready: name,
|
|
187
|
+
client: temporalClient(httpUrl, namespace) };
|
|
188
|
+
}
|