@elench/testkit 0.1.115 → 0.1.116

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.
Files changed (34) hide show
  1. package/README.md +33 -8
  2. package/lib/cli/args.mjs +3 -3
  3. package/lib/cli/command-flags.mjs +4 -0
  4. package/lib/cli/commands/db/schema/refresh.mjs +21 -0
  5. package/lib/cli/commands/db/schema/verify.mjs +27 -0
  6. package/lib/cli/entrypoint.mjs +1 -0
  7. package/lib/cli/operations/db/schema/refresh/operation.mjs +56 -0
  8. package/lib/cli/operations/db/{snapshot/capture → schema/verify}/operation.mjs +6 -27
  9. package/lib/cli/operations/run/operation.mjs +1 -0
  10. package/lib/cli/renderers/db-schema/text.mjs +7 -0
  11. package/lib/config/database.mjs +64 -0
  12. package/lib/config-api/index.d.ts +16 -1
  13. package/lib/config-api/index.mjs +31 -16
  14. package/lib/database/fingerprint.mjs +2 -0
  15. package/lib/database/index.mjs +142 -104
  16. package/lib/database/schema-source.mjs +295 -0
  17. package/lib/database/template-steps.mjs +158 -38
  18. package/lib/runner/orchestrator.mjs +4 -3
  19. package/lib/runner/template-steps.mjs +12 -1
  20. package/lib/runner/template.mjs +16 -1
  21. package/node_modules/@elench/next-analysis/package.json +1 -1
  22. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  23. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  24. package/node_modules/@elench/ts-analysis/package.json +1 -1
  25. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  26. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  27. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  28. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  29. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  30. package/node_modules/esprima/ChangeLog +235 -0
  31. package/package.json +8 -5
  32. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/lib/cli/commands/db/snapshot/capture.mjs +0 -26
  34. package/lib/cli/renderers/db-snapshot-capture/text.mjs +0 -3
package/README.md CHANGED
@@ -70,8 +70,9 @@ npx @elench/testkit assistant --message "/logs api"
70
70
  # Automatic regression intelligence
71
71
  # Configure testkit.regressions.json and testkit classifies new vs known regressions automatically during runs
72
72
 
73
- # Capture a template DB schema snapshot
74
- npx @elench/testkit db snapshot capture --service api --output scripts/testkit/schema-baseline.sql
73
+ # Diagnostics: refresh or verify the source schema cache
74
+ npx @elench/testkit db schema refresh --service api
75
+ npx @elench/testkit db schema verify --service api
75
76
  ```
76
77
 
77
78
  `testkit` is assistant-first in an interactive TTY. The interactive assistant
@@ -251,9 +252,10 @@ export default defineConfig({
251
252
  port: 3004,
252
253
  envFiles: [".env.testkit"],
253
254
  database: database.postgres({
255
+ sourceSchema: database.schema.fromEnv("PRODUCTION_DATABASE_URL"),
254
256
  template: {
255
257
  inputs: ["db/schema.sql", "scripts/seed.ts"],
256
- schema: "db/schema.sql",
258
+ migrate: [{ kind: "sql-file", path: "db/schema.sql" }],
257
259
  seed: [{ kind: "command", run: "npm run db:seed" }],
258
260
  verify: [{ kind: "module", target: "src/testkit/verify-seed.ts#verifySeed" }],
259
261
  },
@@ -306,8 +308,8 @@ for:
306
308
  - repo-managed Node toolchains for prepare/start commands
307
309
  - one-time runtime preparation steps for stable shared servers
308
310
  - local DB binding configuration
311
+ - source-backed schema verification
309
312
  - template database migrate / seed / verify stages
310
- - template schema snapshot capture
311
313
  - explicit per-file or per-suite locks
312
314
  - named HTTP suite profiles
313
315
  - automatic regression classification for new vs known failures
@@ -322,12 +324,28 @@ right way to move expensive browser targets from `next dev` / watch mode to
322
324
  stable build-and-start flows.
323
325
 
324
326
  `database.template` is the database-side equivalent for reusable template DB
325
- state. It always executes in three explicit phases:
327
+ state. When `database.sourceSchema` is configured, Testkit treats the configured
328
+ source database as the schema source of truth. A normal `testkit run` refreshes
329
+ `.testkit/db/<service>/source-schema.sql` from the source database when needed,
330
+ applies that cached schema to the local template DB, runs local template setup,
331
+ and verifies that the replayed local schema still matches the source dump. If
332
+ local replay differs, Testkit refreshes from the source once and retries. If it
333
+ still differs, the run fails with schema diagnostics under
334
+ `.testkit/results/schema`.
335
+
336
+ Template setup executes in three explicit phases:
326
337
 
327
338
  - `migrate`
328
339
  - `seed`
329
340
  - `verify`
330
341
 
342
+ Schema drift is checked after each successful `migrate` and `seed` step, and
343
+ `verify` only runs once local replay matches the source schema. Source refreshes
344
+ use `pg_dump --schema-only --no-owner --no-privileges`, so seed/reference data
345
+ is never written into the baseline. Keep schema-changing setup in its own step
346
+ where possible; a single command that changes schema and then fails before
347
+ exiting cannot be refreshed at the midpoint.
348
+
331
349
  For most repos, prefer declarative step objects directly inside
332
350
  `database.postgres({ template: ... })` and `runtime.prepare.steps`.
333
351
  The supported shapes are:
@@ -667,10 +685,15 @@ services that define `database: database.postgres(...)`.
667
685
  - template databases are cached
668
686
  - runtime databases are cloned from templates when binding is `per-runtime`
669
687
  - shared databases are reused when binding is `shared`
670
- - template fingerprints are derived automatically from env files, migrate/seed
671
- config, and repo contents
688
+ - source schema caches are refreshed only from the configured source database
689
+ - template fingerprints are derived automatically from env files, source schema
690
+ cache, migrate/seed config, and repo contents
672
691
 
673
- Manual `template.inputs` overrides are still available for edge cases.
692
+ `db schema refresh` forces a source database dump into the `.testkit` source
693
+ schema cache. `db schema verify` prepares local templates and verifies local
694
+ replay against the cached/refreshed source schema. `--skip-schema-source-verify`
695
+ is available as a narrow escape hatch when users need to run tests while schema
696
+ verification is temporarily blocked.
674
697
 
675
698
  ## Development Tests
676
699
 
@@ -679,4 +702,6 @@ npm test
679
702
  npm run test:unit
680
703
  npm run test:integration
681
704
  npm run test:system
705
+ npm run test:live:neon
706
+ npm run test:database-version:compat
682
707
  ```
package/lib/cli/args.mjs CHANGED
@@ -16,11 +16,11 @@ export function resolveCliSelection({ first, second, third }) {
16
16
  let dbAction = null;
17
17
 
18
18
  if (first === "db") {
19
- if (second === "snapshot" && third === "capture") {
20
- dbAction = "snapshot-capture";
19
+ if (second === "schema" && (third === "refresh" || third === "verify")) {
20
+ dbAction = `schema-${third}`;
21
21
  return { lifecycle, positionalType, dbAction };
22
22
  }
23
- throw new Error('Unknown db command. Expected "db snapshot capture".');
23
+ throw new Error('Unknown db command. Expected "db schema refresh" or "db schema verify".');
24
24
  }
25
25
 
26
26
  if (second || third) {
@@ -48,6 +48,10 @@ export const runFlags = {
48
48
  description: "Run files even if testkit.config.ts marks them skipped",
49
49
  default: false,
50
50
  }),
51
+ "skip-schema-source-verify": Flags.boolean({
52
+ description: "Skip the check that local schema replay matches the source database schema",
53
+ default: false,
54
+ }),
51
55
  "output-mode": Flags.string({
52
56
  description: "Reporter mode",
53
57
  options: ["compact", "debug", "events"],
@@ -0,0 +1,21 @@
1
+ import { Command } from "@oclif/core";
2
+ import { sharedFlags } from "../../../command-flags.mjs";
3
+ import { executeDatabaseSchemaRefreshOperation } from "../../../operations/db/schema/refresh/operation.mjs";
4
+ import { renderDatabaseSchemaRefreshResult } from "../../../renderers/db-schema/text.mjs";
5
+
6
+ export default class DbSchemaRefreshCommand extends Command {
7
+ static summary = "Refresh a source database schema cache";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static flags = sharedFlags;
12
+
13
+ async run() {
14
+ const { flags } = await this.parse(DbSchemaRefreshCommand);
15
+ const result = await executeDatabaseSchemaRefreshOperation(flags);
16
+ if (!this.jsonEnabled()) {
17
+ for (const line of renderDatabaseSchemaRefreshResult(result)) this.log(line);
18
+ }
19
+ return result;
20
+ }
21
+ }
@@ -0,0 +1,27 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { sharedFlags } from "../../../command-flags.mjs";
3
+ import { executeDatabaseSchemaVerifyOperation } from "../../../operations/db/schema/verify/operation.mjs";
4
+ import { renderDatabaseSchemaVerifyResult } from "../../../renderers/db-schema/text.mjs";
5
+
6
+ export default class DbSchemaVerifyCommand extends Command {
7
+ static summary = "Verify local schema replay against the source database schema";
8
+
9
+ static enableJsonFlag = true;
10
+
11
+ static flags = {
12
+ ...sharedFlags,
13
+ "skip-schema-source-verify": Flags.boolean({
14
+ description: "Skip the check that local schema replay matches the source database schema",
15
+ default: false,
16
+ }),
17
+ };
18
+
19
+ async run() {
20
+ const { flags } = await this.parse(DbSchemaVerifyCommand);
21
+ const result = await executeDatabaseSchemaVerifyOperation(flags);
22
+ if (!this.jsonEnabled()) {
23
+ for (const line of renderDatabaseSchemaVerifyResult(result)) this.log(line);
24
+ }
25
+ return result;
26
+ }
27
+ }
@@ -59,6 +59,7 @@ export function normalizeCliArgs(argv) {
59
59
  "--write-status",
60
60
  "--allow-partial-status",
61
61
  "--ignore-skip-rules",
62
+ "--skip-schema-source-verify",
62
63
  "--output-mode",
63
64
  "--debug",
64
65
  ].includes(value)
@@ -0,0 +1,56 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { loadManagedConfigs, resolveTargetConfig, collectRequiredConfigs, topologicallySortConfigs } from "../../../../../app/configs.mjs";
5
+ import { resolveProductDir } from "../../../../../config/index.mjs";
6
+ import { prepareDatabaseRuntime } from "../../../../../database/index.mjs";
7
+ import { forceRefreshSourceSchemaCache, getSourceSchemaCachePath } from "../../../../../database/schema-source.mjs";
8
+ import { createRunReporter } from "../../../../renderers/run/text-reporter.mjs";
9
+ import { createRunLogRegistry } from "../../../../../runner/logs.mjs";
10
+ import { createSetupOperationRegistry } from "../../../../../runner/setup-operations.mjs";
11
+ import { resolveRuntimeInstanceConfigs } from "../../../../../runner/template.mjs";
12
+
13
+ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
14
+ const productDir = resolveProductDir(process.cwd(), options.dir);
15
+ const { configs } = await loadManagedConfigs({ dir: productDir });
16
+ const target = resolveTargetConfig(configs, options.service);
17
+ const selectedConfigs = collectRequiredConfigs(configs, target.name);
18
+ const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-schema-refresh-"));
19
+ const runtimeDir = path.join(runtimeRoot, "runtime-1");
20
+ const resolvedConfigs = resolveRuntimeInstanceConfigs(selectedConfigs, "runtime-1", runtimeDir, {
21
+ graphDirName: `schema-refresh-${target.name}`,
22
+ portNamespaceIndex: 0,
23
+ portNamespaceStride: 1,
24
+ });
25
+ const resolvedTarget = resolvedConfigs.find((config) => config.name === target.name);
26
+ if (!resolvedTarget) throw new Error(`Resolved runtime config missing target service "${target.name}"`);
27
+
28
+ const reporter = createRunReporter({ outputMode: options.debug ? "debug" : "compact" });
29
+ const logRegistry = createRunLogRegistry(productDir);
30
+ const setupRegistry = createSetupOperationRegistry({ logRegistry });
31
+ try {
32
+ for (const config of topologicallySortConfigs(resolvedConfigs)) {
33
+ if (config.name === resolvedTarget.name) break;
34
+ if (config.testkit.database?.provider === "local") {
35
+ await prepareDatabaseRuntime(config, { reporter, logRegistry, setupRegistry });
36
+ }
37
+ }
38
+ const state = await forceRefreshSourceSchemaCache(resolvedTarget, null, {
39
+ reporter,
40
+ logRegistry,
41
+ setupRegistry,
42
+ });
43
+ const outputPath = getSourceSchemaCachePath(resolvedTarget);
44
+ return {
45
+ ok: true,
46
+ productDir,
47
+ service: target.name,
48
+ outputPath,
49
+ outputLabel: path.relative(productDir, outputPath) || path.basename(outputPath),
50
+ envName: state.envName || null,
51
+ };
52
+ } finally {
53
+ logRegistry.closeAll();
54
+ fs.rmSync(runtimeRoot, { recursive: true, force: true });
55
+ }
56
+ }
@@ -3,69 +3,48 @@ import os from "os";
3
3
  import path from "path";
4
4
  import { loadManagedConfigs, resolveTargetConfig, collectRequiredConfigs, topologicallySortConfigs } from "../../../../../app/configs.mjs";
5
5
  import { resolveProductDir } from "../../../../../config/index.mjs";
6
- import { captureDatabaseTemplateSnapshot, prepareDatabaseRuntime } from "../../../../../database/index.mjs";
6
+ import { prepareDatabaseRuntime } from "../../../../../database/index.mjs";
7
7
  import { createRunReporter } from "../../../../renderers/run/text-reporter.mjs";
8
8
  import { createRunLogRegistry } from "../../../../../runner/logs.mjs";
9
9
  import { createSetupOperationRegistry } from "../../../../../runner/setup-operations.mjs";
10
10
  import { resolveRuntimeInstanceConfigs } from "../../../../../runner/template.mjs";
11
11
 
12
- export async function executeDatabaseSnapshotCaptureOperation(options = {}) {
12
+ export async function executeDatabaseSchemaVerifyOperation(options = {}) {
13
13
  const productDir = resolveProductDir(process.cwd(), options.dir);
14
14
  const { configs } = await loadManagedConfigs({ dir: productDir });
15
15
  const target = resolveTargetConfig(configs, options.service);
16
- const outputPath = normalizeOptionalString(options.output);
17
- if (!outputPath) {
18
- throw new Error("Snapshot capture requires --output");
19
- }
20
-
21
16
  const selectedConfigs = collectRequiredConfigs(configs, target.name);
22
- const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-snapshot-"));
17
+ const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-schema-verify-"));
23
18
  const runtimeDir = path.join(runtimeRoot, "runtime-1");
24
19
  const resolvedConfigs = resolveRuntimeInstanceConfigs(selectedConfigs, "runtime-1", runtimeDir, {
25
- graphDirName: `snapshot-${target.name}`,
20
+ graphDirName: `schema-verify-${target.name}`,
26
21
  portNamespaceIndex: 0,
27
22
  portNamespaceStride: 1,
28
23
  });
29
24
  const resolvedTarget = resolvedConfigs.find((config) => config.name === target.name);
30
- if (!resolvedTarget) {
31
- throw new Error(`Resolved runtime config missing target service "${target.name}"`);
32
- }
25
+ if (!resolvedTarget) throw new Error(`Resolved runtime config missing target service "${target.name}"`);
33
26
 
34
- const absoluteOutputPath = path.resolve(productDir, outputPath);
35
27
  const reporter = createRunReporter({ outputMode: options.debug ? "debug" : "compact" });
36
28
  const logRegistry = createRunLogRegistry(productDir);
37
29
  const setupRegistry = createSetupOperationRegistry({ logRegistry });
38
30
  try {
39
31
  for (const config of topologicallySortConfigs(resolvedConfigs)) {
40
- if (config.name === resolvedTarget.name) continue;
41
32
  if (config.testkit.database?.provider === "local") {
42
33
  await prepareDatabaseRuntime(config, {
43
34
  reporter,
44
35
  logRegistry,
45
36
  setupRegistry,
37
+ skipSchemaSourceVerify: options["skip-schema-source-verify"],
46
38
  });
47
39
  }
48
40
  }
49
- await captureDatabaseTemplateSnapshot(resolvedTarget, absoluteOutputPath, {
50
- reporter,
51
- logRegistry,
52
- setupRegistry,
53
- });
54
41
  return {
55
42
  ok: true,
56
43
  productDir,
57
44
  service: target.name,
58
- outputPath: absoluteOutputPath,
59
- outputLabel: path.relative(productDir, absoluteOutputPath) || path.basename(absoluteOutputPath),
60
45
  };
61
46
  } finally {
62
47
  logRegistry.closeAll();
63
48
  fs.rmSync(runtimeRoot, { recursive: true, force: true });
64
49
  }
65
50
  }
66
-
67
- function normalizeOptionalString(value) {
68
- if (typeof value !== "string") return null;
69
- const normalized = value.trim();
70
- return normalized.length > 0 ? normalized : null;
71
- }
@@ -47,6 +47,7 @@ export async function buildRunRequest(flags, positionalType = null, cwd = proces
47
47
  writeStatus: flags["write-status"],
48
48
  allowPartialStatus: flags["allow-partial-status"],
49
49
  ignoreSkipRules: flags["ignore-skip-rules"],
50
+ skipSchemaSourceVerify: flags["skip-schema-source-verify"],
50
51
  },
51
52
  };
52
53
  }
@@ -0,0 +1,7 @@
1
+ export function renderDatabaseSchemaRefreshResult(result) {
2
+ return [`Refreshed ${result.outputLabel}`];
3
+ }
4
+
5
+ export function renderDatabaseSchemaVerifyResult(result) {
6
+ return [`Verified source schema for ${result.service}`];
7
+ }
@@ -29,6 +29,7 @@ export function normalizeDatabaseConfig(explicitService, serviceName) {
29
29
  image: database.image || DEFAULT_LOCAL_IMAGE,
30
30
  user: database.user || DEFAULT_LOCAL_USER,
31
31
  password: database.password || DEFAULT_LOCAL_PASSWORD,
32
+ sourceSchema: normalizeSourceSchemaConfig(database.sourceSchema, serviceName),
32
33
  template: normalizeDatabaseTemplateConfig(database.template, serviceName),
33
34
  serviceName,
34
35
  };
@@ -46,6 +47,11 @@ export function normalizeDatabaseTemplateConfig(value, serviceName) {
46
47
  if (!value || typeof value !== "object") {
47
48
  throw new Error(`Service "${serviceName}" database.template must be an object`);
48
49
  }
50
+ if (Object.prototype.hasOwnProperty.call(value, "schema")) {
51
+ throw new Error(
52
+ `Service "${serviceName}" database.template.schema has been removed. Configure database.sourceSchema instead.`
53
+ );
54
+ }
49
55
 
50
56
  return {
51
57
  inputs: normalizeConfiguredInputs(value.inputs, `Service "${serviceName}" database.template`),
@@ -54,3 +60,61 @@ export function normalizeDatabaseTemplateConfig(value, serviceName) {
54
60
  verify: normalizeConfiguredSteps(value.verify, `Service "${serviceName}" database.template.verify`),
55
61
  };
56
62
  }
63
+
64
+ function normalizeSourceSchemaConfig(value, serviceName) {
65
+ if (value === false || value === null) return null;
66
+ if (value === undefined) {
67
+ return {
68
+ kind: "auto",
69
+ cachePath: null,
70
+ refresh: { mode: "always" },
71
+ unavailable: "auto",
72
+ verify: true,
73
+ };
74
+ }
75
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
76
+ throw new Error(`Service "${serviceName}" database.sourceSchema must be an object or false`);
77
+ }
78
+ const kind = value.kind || "env";
79
+ if (kind !== "env") {
80
+ throw new Error(`Service "${serviceName}" database.sourceSchema.kind must be "env"`);
81
+ }
82
+ if (typeof value.env !== "string" || value.env.trim().length === 0) {
83
+ throw new Error(`Service "${serviceName}" database.sourceSchema.env must be a non-empty string`);
84
+ }
85
+ return {
86
+ kind,
87
+ env: value.env.trim(),
88
+ cachePath: normalizeOptionalString(value.cachePath, `Service "${serviceName}" database.sourceSchema.cachePath`),
89
+ refresh: normalizeSourceSchemaRefresh(value.refresh, serviceName),
90
+ unavailable: normalizeSourceSchemaUnavailable(value.unavailable, serviceName),
91
+ verify: value.verify !== false,
92
+ };
93
+ }
94
+
95
+ function normalizeSourceSchemaRefresh(value, serviceName) {
96
+ if (value == null || value === "always") return { mode: "always" };
97
+ if (typeof value === "object" && !Array.isArray(value)) {
98
+ const ttlSeconds = value.ttlSeconds;
99
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 0) {
100
+ throw new Error(`Service "${serviceName}" database.sourceSchema.refresh.ttlSeconds must be a non-negative integer`);
101
+ }
102
+ return { mode: "ttl", ttlSeconds };
103
+ }
104
+ throw new Error(`Service "${serviceName}" database.sourceSchema.refresh must be "always" or { ttlSeconds }`);
105
+ }
106
+
107
+ function normalizeSourceSchemaUnavailable(value, serviceName) {
108
+ if (value == null || value === "auto" || value === "fail" || value === "warn-cache") {
109
+ return value || "auto";
110
+ }
111
+ throw new Error(`Service "${serviceName}" database.sourceSchema.unavailable must be "auto", "fail", or "warn-cache"`);
112
+ }
113
+
114
+ function normalizeOptionalString(value, label) {
115
+ if (value == null) return null;
116
+ if (typeof value !== "string" || value.trim().length === 0) {
117
+ throw new Error(`${label} must be a non-empty string`);
118
+ }
119
+ return value.trim();
120
+ }
@@ -9,12 +9,23 @@ export interface DatabaseTemplateConfig {
9
9
 
10
10
  export interface DatabaseTemplateOptions {
11
11
  inputs?: string[];
12
- schema?: string | TemplateSqlFileStepConfig;
13
12
  migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
14
13
  seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
15
14
  verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
16
15
  }
17
16
 
17
+ export interface DatabaseSourceSchemaOptions {
18
+ cachePath?: string;
19
+ refresh?: "always" | { ttlSeconds: number };
20
+ unavailable?: "auto" | "fail" | "warn-cache";
21
+ verify?: boolean;
22
+ }
23
+
24
+ export interface DatabaseSourceSchemaConfig extends DatabaseSourceSchemaOptions {
25
+ kind: "env";
26
+ env: string;
27
+ }
28
+
18
29
  export interface TemplateStepBaseConfig {
19
30
  cwd?: string;
20
31
  inputs?: string[];
@@ -78,6 +89,7 @@ export interface LocalDatabaseConfig {
78
89
  image?: string;
79
90
  password?: string;
80
91
  reset?: boolean;
92
+ sourceSchema?: DatabaseSourceSchemaConfig | null;
81
93
  template?: DatabaseTemplateConfig;
82
94
  user?: string;
83
95
  }
@@ -409,6 +421,9 @@ export declare const app: {
409
421
  next(options: NextAppOptions): ServiceConfig;
410
422
  };
411
423
  export declare const database: {
424
+ schema: {
425
+ fromEnv(envName: string, options?: DatabaseSourceSchemaOptions): DatabaseSourceSchemaConfig;
426
+ };
412
427
  postgres(
413
428
  options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
414
429
  template?: DatabaseTemplateOptions;
@@ -34,19 +34,36 @@ function postgresDatabase(options = {}) {
34
34
  }
35
35
 
36
36
  function buildDatabaseTemplateConfig(options = {}) {
37
+ for (const legacyKey of ["schema"]) {
38
+ if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
39
+ throw new Error(
40
+ `database.postgres({ template }) no longer accepts "${legacyKey}". Use database.postgres({ sourceSchema: database.schema.fromEnv(...) }) instead.`
41
+ );
42
+ }
43
+ }
37
44
  const migrate = normalizeTemplateStepList(options.migrate);
38
45
  const seed = normalizeTemplateStepList(options.seed);
39
46
  const verify = normalizeTemplateStepList(options.verify);
40
- const schema = normalizeSchemaStep(options.schema);
41
47
 
42
48
  return {
43
49
  inputs: Array.isArray(options.inputs) ? [...options.inputs] : undefined,
44
- migrate: schema ? [schema, ...migrate] : migrate,
50
+ migrate,
45
51
  seed,
46
52
  verify,
47
53
  };
48
54
  }
49
55
 
56
+ function sourceSchemaFromEnv(envName, options = {}) {
57
+ if (typeof envName !== "string" || envName.trim().length === 0) {
58
+ throw new Error("database.schema.fromEnv(...) requires a non-empty env var name");
59
+ }
60
+ return {
61
+ kind: "env",
62
+ env: envName.trim(),
63
+ ...options,
64
+ };
65
+ }
66
+
50
67
  function postgresFixture(options = {}) {
51
68
  const { discovery, envFiles, template, ...databaseOptions } = options;
52
69
  for (const legacyKey of ["inputs", "schema", "migrate", "seed", "verify"]) {
@@ -241,8 +258,11 @@ export const app = {
241
258
  };
242
259
 
243
260
  export const database = {
261
+ schema: {
262
+ fromEnv: sourceSchemaFromEnv,
263
+ },
244
264
  postgres(options = {}) {
245
- const { template, ...databaseOptions } = options;
265
+ const { template, sourceSchema, ...databaseOptions } = options;
246
266
  for (const legacyKey of ["inputs", "schema", "migrate", "seed", "verify"]) {
247
267
  if (Object.prototype.hasOwnProperty.call(options, legacyKey)) {
248
268
  throw new Error(
@@ -250,13 +270,19 @@ export const database = {
250
270
  );
251
271
  }
252
272
  }
273
+ const normalizedDatabaseOptions = sourceSchema === undefined
274
+ ? databaseOptions
275
+ : {
276
+ ...databaseOptions,
277
+ sourceSchema,
278
+ };
253
279
  return postgresDatabase(
254
280
  template
255
281
  ? {
256
- ...databaseOptions,
282
+ ...normalizedDatabaseOptions,
257
283
  template: buildDatabaseTemplateConfig(template),
258
284
  }
259
- : databaseOptions
285
+ : normalizedDatabaseOptions
260
286
  );
261
287
  },
262
288
  fixture: postgresFixture,
@@ -365,17 +391,6 @@ function normalizeTemplateStepList(value) {
365
391
  return Array.isArray(value) ? [...value] : [value];
366
392
  }
367
393
 
368
- function normalizeSchemaStep(value) {
369
- if (value == null) return null;
370
- if (typeof value === "string") {
371
- return {
372
- kind: "sql-file",
373
- path: value,
374
- };
375
- }
376
- return value;
377
- }
378
-
379
394
  function compiledEntryFromSource(entry, outDir) {
380
395
  const normalized = String(entry || "src/index.ts").replaceAll("\\", "/");
381
396
  const compiled = normalized.replace(/\.[cm]?[jt]sx?$/i, ".js");
@@ -3,6 +3,7 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import { resolveServiceCwd } from "../config/paths.mjs";
5
5
  import { collectTemplateInputs } from "./template-steps.mjs";
6
+ import { appendSourceSchemaCacheToHash } from "./schema-source.mjs";
6
7
 
7
8
  const LOCAL_IMAGE = "pgvector/pgvector:pg16";
8
9
  const LOCAL_USER = "testkit";
@@ -24,6 +25,7 @@ export async function computeTemplateFingerprint(config) {
24
25
  for (const input of collectTemplateInputs(config.productDir, db.template || {})) {
25
26
  appendResolvedInputToHash(hash, config.productDir, input);
26
27
  }
28
+ appendSourceSchemaCacheToHash(hash, config);
27
29
 
28
30
  return hash.digest("hex");
29
31
  }