@gfdelarue/testkit 0.3.3 → 0.3.4

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/README.md CHANGED
@@ -1068,6 +1068,13 @@ platform's host address.
1068
1068
  - template fingerprints are derived automatically from env files, source schema
1069
1069
  cache, migrate/seed config, and repo contents
1070
1070
 
1071
+ The service process receives its provisioned database as `DATABASE_URL`. Tasks
1072
+ get it only through declared `env` values: use the `{dbUrl}`, `{dbHost}`,
1073
+ `{dbPort}`, `{dbName}`, `{dbUser}`, `{dbPassword}`, and `{dbSsl}` tokens, with
1074
+ `:serviceName` to read the database of a service listed in `dependsOn`. Testkit
1075
+ resolves them from the database provisioned for the current run. They are
1076
+ env-only; see [`env`](testkit-config-schema.md#env).
1077
+
1071
1078
  `db schema refresh` forces a source database dump into the `.testkit` source
1072
1079
  schema cache. `db schema verify` prepares local templates and verifies local
1073
1080
  replay against the cached/refreshed source schema. `--skip-schema-source-verify`
@@ -1 +1 @@
1
- import{validateConfiguredCollection as f}from"../../steps/configured-steps.js";import{resolveEngineProfile as $}from"../../engines/catalog.js";import{ensureExistingContainedPath as m,ensureExistingPath as h}from"./paths.js";const S=["migrate","seed","verify"];export function validateConfigCoverage(e){const n=new Set(e.map(t=>t.name));for(const t of e){for(const r of t.testkit.dependsOn)if(!n.has(r))throw new Error(`Service "${t.name}" depends on "${r}", but ${r} is not defined`);const s=t.testkit.databaseFrom;if(s&&!n.has(s))throw new Error(`Service "${t.name}" databaseFrom "${s}" is not defined`)}}export function validateServiceConfig({name:e,local:n,database:t,databaseFrom:s,runtime:r,dependsOn:d,suites:p,productDir:a,requireLocalRuntime:l=!0}){const w=Object.entries(p).some(([o,v])=>(v||[]).some(i=>{if(i.testType!==o)throw new Error(`Service "${e}" suite ${o}:${i.name} declares conflicting test type ${i.testType}`);const c=$(i.testType,i.engineId);if(c.id!==i.profileId)throw new Error(`Service "${e}" suite ${i.testType}:${i.name} declares conflicting engine profile ${i.profileId}; expected ${c.id}`);return c.capabilities.applicationRuntime==="required"}));if(l&&w&&!n)throw new Error(`Service "${e}" defines suites whose engine profile requires an application runtime, but no local runtime could be resolved. Add it in testkit.config.ts.`);if(t&&s)throw new Error(`Service "${e}" cannot define both database and databaseFrom`);if(r.instances<1)throw new Error(`Service "${e}" runtime.instances must be a positive integer`);if(r.maxConcurrentTasks<=0)throw new Error(`Service "${e}" runtime.maxConcurrentTasks must be a positive integer when provided`);for(const o of d)if(o===e)throw new Error(`Service "${e}" cannot depend on itself`);if(n?.cwd&&h(a,n.cwd,`Service "${e}" local.cwd`),r.toolchain?.cwd&&m(a,r.toolchain.cwd,`Service "${e}" runtime.toolchain.cwd`),t)for(const o of S)f({productDir:a,label:`Service "${e}" database.template.${o}`,steps:t.template[o]});f({productDir:a,label:`Service "${e}" database.template`,inputs:t?.template.inputs||[]}),f({productDir:a,label:`Service "${e}" runtime.prepare`,inputs:r.prepare.inputs,steps:r.prepare.steps})}
1
+ import{finalizeConfiguredInputs as l,finalizeConfiguredSteps as w,validateConfiguredCollection as d}from"../../steps/configured-steps.js";import{findDatabasePlaceholders as h}from"../../steps/environment.js";import{resolveEngineProfile as S}from"../../engines/catalog.js";import{ensureExistingContainedPath as g,ensureExistingPath as E}from"./paths.js";const m=["migrate","seed","verify"];export function validateConfigCoverage(e){const i=new Set(e.map(r=>r.name));for(const r of e){for(const t of r.testkit.dependsOn)if(!i.has(t))throw new Error(`Service "${r.name}" depends on "${t}", but ${t} is not defined`);const n=r.testkit.databaseFrom;if(n&&!i.has(n))throw new Error(`Service "${r.name}" databaseFrom "${n}" is not defined`)}}export function validateServiceConfig({name:e,local:i,database:r,databaseFrom:n,runtime:t,dependsOn:c,suites:p,productDir:s,requireLocalRuntime:v=!0}){const $=Object.entries(p).some(([a,u])=>(u||[]).some(o=>{if(o.testType!==a)throw new Error(`Service "${e}" suite ${a}:${o.name} declares conflicting test type ${o.testType}`);const f=S(o.testType,o.engineId);if(f.id!==o.profileId)throw new Error(`Service "${e}" suite ${o.testType}:${o.name} declares conflicting engine profile ${o.profileId}; expected ${f.id}`);return f.capabilities.applicationRuntime==="required"}));if(v&&$&&!i)throw new Error(`Service "${e}" defines suites whose engine profile requires an application runtime, but no local runtime could be resolved. Add it in testkit.config.ts.`);if(r&&n)throw new Error(`Service "${e}" cannot define both database and databaseFrom`);if(t.instances<1)throw new Error(`Service "${e}" runtime.instances must be a positive integer`);if(t.maxConcurrentTasks<=0)throw new Error(`Service "${e}" runtime.maxConcurrentTasks must be a positive integer when provided`);for(const a of c)if(a===e)throw new Error(`Service "${e}" cannot depend on itself`);if(b({name:e,local:i,database:r,runtime:t}),i?.cwd&&E(s,i.cwd,`Service "${e}" local.cwd`),t.toolchain?.cwd&&g(s,t.toolchain.cwd,`Service "${e}" runtime.toolchain.cwd`),r)for(const a of m)d({productDir:s,label:`Service "${e}" database.template.${a}`,steps:r.template[a]});d({productDir:s,label:`Service "${e}" database.template`,inputs:r?.template.inputs||[]}),d({productDir:s,label:`Service "${e}" runtime.prepare`,inputs:t.prepare.inputs,steps:t.prepare.steps})}function b({name:e,local:i,database:r,runtime:n}){const t=c=>p=>{const s=h(p).at(0);if(s)throw new Error(`Service "${e}" ${c} uses ${s.placeholder}. Database placeholders resolve only in env values; commands read DATABASE_URL or a service env entry at run time.`);return p};if(i&&(t("local.start")(i.start),t("local.cwd")(i.cwd)),l(n.prepare.inputs,t("runtime.prepare.inputs")),w(n.prepare.steps,t("runtime.prepare.steps")),r){l(r.template.inputs,t("database.template.inputs"));for(const c of m)w(r.template[c],t(`database.template.${c}`))}}
@@ -1,3 +1,3 @@
1
- import o from"fs";import s from"path";import{createRequire as J}from"node:module";import{collectRepoState as W,summarizeRepoStateForMetadata as C}from"../repo/state.js";import{buildExecutionEnv as V}from"../steps/index.js";import{dumpPostgresSchemaToFile as X,captureTemplateSnapshotText as Z,resolvePgDumpClientImage as G,runTemplateStep as Q,sanitizeSnapshotText as Y}from"./template-steps.js";import{getSourceSchemaRefreshLockPath as ee,withSourceSchemaRefreshLock as re}from"./source-refresh-lock.js";import{resolveSourceSchemaDumpUrl as te}from"./source-url.js";import{makeTempDir as ae,removeTempDir as ce}from"../process/files/temp-dir.js";import{requireContainedDatabaseStateDirectory as R,resolveContainedDatabaseStateDirectory as D}from"./state.js";const k=J(import.meta.url);let w;function ne(){if(w)return w;const e=k.resolve("diff/package.json"),t=JSON.parse(o.readFileSync(e,"utf8"))?.exports?.["."]?.import?.default;if(typeof t!="string"||!t.startsWith("./"))throw new Error("The diff package must declare a local ESM import entry");const c=s.dirname(e),a=s.resolve(c,t),n=s.relative(c,a);if(!n||n===".."||n.startsWith(`..${s.sep}`)||s.isAbsolute(n))throw new Error("The diff package ESM import entry must stay within its package directory");return w=k(a)}const K="db",U="source-schemas",P="source-schema.sql",E="source-schema.meta.json",se="index.json",M=20,p=String;export function getSourceSchemaRootDir(e){return D(e.productDir,K,e.name,U)}export async function getSourceSchemaCachePath(e,r={}){return(await resolveSourceSchemaCacheLocation(e,r)).cachePath}export async function resolveSourceSchemaCacheLocation(e,r={}){const t=r.repoState||await W(e.productDir,{fingerprints:e.testkit?.fingerprints}),c=getSourceSchemaRootDir(e),a=D(e.productDir,K,e.name,U,...t.cacheKey.split("/").map(v));return{rootDir:c,cacheDir:a,cachePath:s.join(a,P),metadataPath:s.join(a,E),indexPath:s.join(c,se),cacheKey:t.cacheKey,cacheKind:t.kind,repoState:t}}export async function resolveSourceSchemaState(e,r={}){const t=e.testkit.database?.sourceSchema||null;if(!t)return{active:!1};const c=await resolveSourceSchemaCacheLocation(e,r),a=c.cachePath,n=o.existsSync(a);n&&A(a);const i=r.env||process.env,u=V(e,{},i),h=(t.kind==="env"?[t.env]:[T(e.name),"TESTKIT_SCHEMA_SOURCE_DATABASE_URL"]).find(z=>{const _=u[z];return typeof _=="string"&&_.trim().length>0})||null,S=h?u[h]:void 0,f=typeof S=="string"?S.trim():null;return t.kind==="env"||!!f||n?{active:!0,cachePath:a,metadataPath:c.metadataPath,indexPath:c.indexPath,rootDir:c.rootDir,cacheDir:c.cacheDir,cacheKey:c.cacheKey,cacheKind:c.cacheKind,repoState:c.repoState,envName:h,sourceUrl:f,sourceSchema:t,cacheExists:n,refreshed:!1,ci:u.CI==="true",unavailableMode:Ee(t.unavailable,i)}:{active:!1,cachePath:a}}export async function prepareSourceSchemaCache(e,r={}){r.signal?.throwIfAborted();const t=await resolveSourceSchemaState(e,r);if(!t.active)return t;if(H(t)){if(de(t))try{const a=await F(e,t,r),n={...t,refreshed:!0,cacheExists:!0,refreshInfo:a};return y(e,n,{refreshed:!0}),n}catch(a){r.signal?.throwIfAborted();const n=ie(e,t,a,r);if(n)return n;throw a}return y(e,t),r.setupRegistry?.recordCached({config:e,stage:"source-schema",kind:"database-source-schema",summary:"source schema cache hit"}),t}if(t.cacheExists&&t.unavailableMode==="warn-cache")return y(e,t),r.setupRegistry?.recordCached({config:e,stage:"source-schema",kind:"database-source-schema",summary:"source unavailable; using cached source schema"}),t;const c=t.sourceSchema.kind==="env"?t.sourceSchema.env:`${T(e.name)} or TESTKIT_SCHEMA_SOURCE_DATABASE_URL`;throw new Error(`Source schema database URL is unavailable for service "${e.name}". Set ${c}.`)}function ie(e,r,t,c={}){if(r.unavailableMode!=="warn-cache")return null;const a=oe(e,r);if(!a)return null;const n={...r,...a,cacheExists:!0,refreshed:!1,refreshError:be(t)};return c.setupRegistry?.recordCached({config:e,stage:"source-schema",kind:"database-source-schema",summary:`source unavailable; using cached source schema: ${s.relative(e.productDir,a.cachePath)}`}),n}function oe(e,r){const t=$(e,r,{cachePath:r.cachePath,metadataPath:r.metadataPath,cacheKey:r.cacheKey,cacheKind:r.cacheKind});if(t)return t;const c=j(r.indexPath);c.sort((a,n)=>Date.parse(n.lastUsedAt||n.refreshedAt||"0")-Date.parse(a.lastUsedAt||a.refreshedAt||"0"));for(const a of c){if(!a.cachePath)continue;const n=s.resolve(e.productDir,a.cachePath),i=$(e,r,{cachePath:n,metadataPath:s.join(s.dirname(n),E),cacheKey:a.cacheKey,cacheKind:a.kind||"unknown"});if(i)return i}return null}function $(e,r,t){const c=s.resolve(t.cachePath);if(s.basename(c)!==P)return null;const a=R(e.productDir,s.dirname(c),`Service "${e.name}" source schema cache directory`),n=s.join(a,P),i=s.resolve(r.rootDir);if(n!==i&&!n.startsWith(`${i}${s.sep}`)||!o.existsSync(n))return null;const u=o.lstatSync(n);if(u.isSymbolicLink()||!u.isFile())throw new Error(`Refusing unsafe source schema cache file at ${n}`);return{cachePath:n,metadataPath:s.join(a,E),cacheDir:a,cacheKey:t.cacheKey,cacheKind:t.cacheKind,indexPath:r.indexPath,rootDir:r.rootDir}}export async function forceRefreshSourceSchemaCache(e,r,t={}){const c=await resolveSourceSchemaState(e,t);if(!c.active||!H(c)){const i=r?.active?r.envName:null,u=r?.active&&r.sourceSchema.kind==="env"?r.sourceSchema.env:null,m=i||u||"source schema env";throw new Error(`Cannot refresh source schema for service "${e.name}" because ${m} is unavailable.`)}const a=await F(e,c,t),n={...c,refreshed:!0,cacheExists:!0,refreshInfo:a};return y(e,n,{refreshed:!0}),n}export async function applySourceSchemaCache(e,r,t,c={}){if(!t?.active)return!1;if(!o.existsSync(t.cachePath))throw new Error(`Missing source schema cache for service "${e.name}": ${t.cachePath}`);const a={kind:"sql-file",path:s.relative(e.productDir,t.cachePath).split(s.sep).join("/"),cwd:null,inputs:[]};return await Q(e,"source-schema",a,0,r,c),!0}export async function verifyLocalSchemaMatchesSource(e,r,t,c={}){if(!t?.active)return{status:"disabled"};if(!t.sourceSchema.verify||c.skipSchemaSourceVerify)return c.setupRegistry?.recordCached({config:e,stage:"source-schema:verify",kind:"database-source-schema",summary:"source schema verification skipped"}),{status:"skipped"};ye(e);const a=le(t.cachePath),n=await Z(e,r,{image:await c.resolveLocalSnapshotImage?.(),ownedContainer:c.ownedContainer||null,reporter:c.reporter||null,signal:c.signal});return n===a?{status:"matched"}:{status:"mismatch",diagnostics:await De(e,t,a,n)}}export function createSourceSchemaMismatchError(e,r,t){const c=[`Local schema replay does not match source schema for service "${e.name}".`,`Source cache: ${s.relative(e.productDir,r.cachePath)}`];return t?.status==="mismatch"&&c.push(`Diff: ${s.relative(e.productDir,t.diagnostics.diffPath)}`),new Error(c.join(`
2
- `))}export async function appendSourceSchemaCacheToHash(e,r,t=null){if(!(r.testkit.database?.sourceSchema||null))return;const a=t?.cachePath||await getSourceSchemaCachePath(r),n=t?.active&&t.cacheKey?t.cacheKey:s.relative(getSourceSchemaRootDir(r),s.dirname(a));if(e.update(`source-schema-key:${n}`),e.update(`source-schema-cache:${s.relative(r.productDir,a)}`),!o.existsSync(a)){e.update(":missing");return}const i=o.statSync(a);e.update(`:${i.size.toString()}:${i.mtimeMs.toString()}`),e.update(o.readFileSync(a))}async function F(e,r,t={}){const c=t.setupRegistry,a=c?.start({config:e,stage:"source-schema:refresh",kind:"database-source-schema",summary:`source schema refresh: ${s.relative(e.productDir,r.cachePath)}`});try{const n=await re(ee(r.cachePath),async({waited:u,requestedAt:m})=>{const h=me(r,m);if(h)return{reusedExistingRefresh:!0,waitedForLock:u,metadata:h};const S=await ue(e,r.sourceUrl,r.cachePath,{reporter:t.reporter||null,logRecord:a?._logRecord||null,env:t.env||process.env,signal:t.signal}),f=he(e,r,S);return Se(r.metadataPath,f),{reusedExistingRefresh:!1,waitedForLock:u,metadata:f}},{signal:t.signal}),i=a&&c?c.finish(a,{status:"passed",summary:`source schema refresh: ${s.relative(e.productDir,r.cachePath)}`}):null;return i&&t.reporter?.setupOperationFinished?.(i),n}catch(n){const i=a?c?.finish(a,{status:"failed",summary:`source schema refresh: ${s.relative(e.productDir,r.cachePath)}`,error:Ae(n)}):null;throw i&&t.reporter?.setupOperationFinished?.(i),n}}async function ue(e,r,t,c={}){const a=te(r);o.mkdirSync(s.dirname(t),{recursive:!0});const n=ae(".source-schema-",{parentDir:s.dirname(t)}),i=s.join(n,s.basename(t));try{const u=await G(e,a.dumpUrl,{env:c.env||process.env,pgApplicationName:g(e.name),signal:c.signal});return await X(e,i,a.dumpUrl,{...c,image:u,pgApplicationName:g(e.name)}),o.renameSync(i,t),{resolution:a,pgDump:{args:["--schema-only","--no-owner","--no-privileges"],applicationName:g(e.name),image:u}}}finally{ce(n)}}function he(e,r,t){return{refreshedAt:new Date().toISOString(),serviceName:e.name,envName:r.envName,cacheKey:r.cacheKey,cacheKind:r.cacheKind,cachePath:s.relative(e.productDir,r.cachePath),repo:C(r.repoState),sourceUrl:t.resolution.metadata,pgDump:t.pgDump}}function me(e,r){if(!o.existsSync(e.cachePath))return null;const t=b(e.metadataPath);if(!Ce(t))return null;const c=Date.parse(t.refreshedAt);return!Number.isFinite(c)||c<r?null:t}function g(e){return`testkit:schema-source:${e.replace(/[^a-zA-Z0-9._:-]+/g,"-").slice(0,24)||"service"}`}function le(e){return Y(o.readFileSync(e,"utf8"))}function de(e){if(!e.cacheExists)return!0;if(e.sourceSchema.refresh.mode==="auto")return e.ci;if(e.sourceSchema.refresh.mode==="ttl"){const r=x(e.metadataPath),t=r?Date.parse(r):0;return!Number.isFinite(t)||t<=0?!0:Date.now()-t>=e.sourceSchema.refresh.ttlSeconds*1e3}return!0}function y(e,r,t={}){if(!r?.active)return;const c=new Date().toISOString(),a=j(r.indexPath).filter(u=>u.cacheKey!==r.cacheKey),n=x(r.metadataPath);a.push({cacheKey:r.cacheKey,kind:r.cacheKind,commitSha:r.repoState.commitSha||null,branch:r.repoState.branch||null,detached:r.repoState.detached,dirty:r.repoState.dirty,dirtyFingerprint:r.repoState.dirtyFingerprint||null,contentFingerprint:r.repoState.contentFingerprint||null,remoteUrl:r.repoState.remoteUrl||null,repository:r.repoState.repository,cachePath:s.relative(e.productDir,r.cachePath),refreshedAt:t.refreshed?c:n,lastUsedAt:c});const i=fe(e,r,a);N(r.indexPath,{version:1,entries:i})}function fe(e,r,t){if(t.length<=M)return t;const c=[...t].sort((n,i)=>Date.parse(i.lastUsedAt||"0")-Date.parse(n.lastUsedAt||"0")),a=new Set(c.slice(0,M).map(n=>n.cacheKey));a.add(r.cacheKey);for(const n of t){if(a.has(n.cacheKey))continue;const i=n.cachePath?s.join(e.productDir,n.cachePath):null,u=i?s.resolve(i):null,m=s.resolve(r.rootDir);if(!i||!u||u!==m&&!u.startsWith(`${m}${s.sep}`))continue;const h=R(e.productDir,s.dirname(i),`Service "${e.name}" pruned source schema cache directory`);o.rmSync(h,{recursive:!0,force:!0})}return t.filter(n=>a.has(n.cacheKey))}function Se(e,r){I(e,r)}function N(e,r){I(e,r)}function I(e,r){o.mkdirSync(s.dirname(e),{recursive:!0}),A(e),o.writeFileSync(e,ge(r))}function b(e){if(!e||!o.existsSync(e))return null;A(e);try{return JSON.parse(o.readFileSync(e,"utf8"))}catch{return null}}function L(e){return D(e.productDir,"results","schema")}function A(e){if(!o.existsSync(e))return;const r=o.lstatSync(e);if(r.isSymbolicLink()||!r.isFile())throw new Error(`Refusing unsafe source schema state file at ${e}`)}function pe(e){return`${v(e.runtimeLabel||e.name)}__${v(e.name)}`}function ye(e){const r=L(e);if(!o.existsSync(r))return;const t=v(e.name);for(const c of o.readdirSync(r))ve(c,t)&&o.rmSync(s.join(r,c),{force:!0})}function ve(e,r){for(const t of["__source-schema.sql","__local-replay-schema.sql","__schema.diff","__schema.meta.json"])if(e.endsWith(t))return e.slice(0,-t.length).endsWith(`__${r}`);return!1}async function De(e,r,t,c){const a=L(e);o.mkdirSync(a,{recursive:!0});const n=pe(e),i=s.join(a,`${n}__source-schema.sql`),u=s.join(a,`${n}__local-replay-schema.sql`),m=s.join(a,`${n}__schema.diff`),h=s.join(a,`${n}__schema.meta.json`);return o.writeFileSync(i,t),o.writeFileSync(u,c),o.writeFileSync(m,await Pe(i,u)),N(h,we(e,r)),{sourceCachePath:r.cachePath,sourcePath:i,localPath:u,diffPath:m,metaPath:h}}function we(e,r){const t=x(r.metadataPath);return{version:1,createdAt:new Date().toISOString(),serviceName:e.name,runtimeLabel:e.runtimeLabel||e.name,sourceSchema:{envName:r.envName||null,cacheKey:r.cacheKey||null,cacheKind:r.cacheKind||null,cachePath:s.relative(e.productDir,r.cachePath),refreshedAt:t},repo:C(r.repoState)}}function Pe(e,r){return new Promise(t=>{t(buildUnifiedDiffText(o.readFileSync(e,"utf8"),o.readFileSync(r,"utf8"),e,r))})}export function buildUnifiedDiffText(e,r,t="source",c="target"){if(e===r)return"";const{createTwoFilesPatch:a}=ne();return a(t,c,p(e),p(r),"","",{context:3})}function Ee(e,r){return e==="fail"||e==="warn-cache"?e:r.CI==="true"?"fail":"warn-cache"}function T(e){return`TESTKIT_${e.toUpperCase().replace(/[^A-Z0-9]+/g,"_")}_SCHEMA_SOURCE_DATABASE_URL`}function v(e){return e.trim().toLowerCase().replace(/[^a-z0-9._-]+/g,"-").replace(/^-+|-+$/g,"")||"schema"}function j(e){const r=b(e);return!d(r)||!Array.isArray(r.entries)?[]:r.entries.filter(xe)}function x(e){const r=b(e);return d(r)&&typeof r.refreshedAt=="string"?r.refreshedAt:null}function ge(e){return`${p(JSON.stringify(e,null,2))}
3
- `}function be(e){const r=O(e,"message");return!!r?r:p(e)}function Ae(e){const r=O(e,"message");return!!r?r:e}function O(e,r){if(!(e===null||typeof e!="object"&&typeof e!="function"))return Reflect.get(e,r)}function H(e){return!!e.sourceUrl}function xe(e){return!d(e)||typeof e.cacheKey!="string"||e.cacheKey.length===0?!1:l(e.branch)&&l(e.cachePath)&&l(e.commitSha)&&l(e.contentFingerprint)&&B(e.detached)&&B(e.dirty)&&l(e.dirtyFingerprint)&&l(e.kind)&&l(e.lastUsedAt)&&l(e.refreshedAt)&&l(e.remoteUrl)&&_e(e.repository)}function _e(e){return e==null||d(e)&&typeof e.host=="string"&&typeof e.owner=="string"&&typeof e.name=="string"}function Ce(e){return d(e)&&typeof e.refreshedAt=="string"&&(e.sourceUrl===void 0||Re(e.sourceUrl))}function Re(e){return d(e)&&typeof e.originalHost=="string"&&q(e.originalClassification)&&typeof e.resolvedHost=="string"&&q(e.resolvedClassification)&&typeof e.rewritten=="boolean"}function q(e){return e==="neon-pooler"||e==="neon-direct"||e==="unsupported-pooler"||e==="unknown-direct"}function ke(e){return e===null||typeof e=="string"}function l(e){return e===void 0||ke(e)}function B(e){return e===void 0||typeof e=="boolean"}function d(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)}
1
+ import o from"fs";import s from"path";import{createRequire as J}from"node:module";import{collectRepoState as W,summarizeRepoStateForMetadata as C}from"../repo/state.js";import{findDatabasePlaceholders as V,resolveExecutionEnvValues as X}from"../steps/index.js";import{dumpPostgresSchemaToFile as Z,captureTemplateSnapshotText as G,resolvePgDumpClientImage as Q,runTemplateStep as Y,sanitizeSnapshotText as ee}from"./template-steps.js";import{getSourceSchemaRefreshLockPath as re,withSourceSchemaRefreshLock as te}from"./source-refresh-lock.js";import{resolveSourceSchemaDumpUrl as ae}from"./source-url.js";import{makeTempDir as ce,removeTempDir as ne}from"../process/files/temp-dir.js";import{requireContainedDatabaseStateDirectory as R,resolveContainedDatabaseStateDirectory as w}from"./state.js";const k=J(import.meta.url);let D;function se(){if(D)return D;const e=k.resolve("diff/package.json"),t=JSON.parse(o.readFileSync(e,"utf8"))?.exports?.["."]?.import?.default;if(typeof t!="string"||!t.startsWith("./"))throw new Error("The diff package must declare a local ESM import entry");const c=s.dirname(e),a=s.resolve(c,t),n=s.relative(c,a);if(!n||n===".."||n.startsWith(`..${s.sep}`)||s.isAbsolute(n))throw new Error("The diff package ESM import entry must stay within its package directory");return D=k(a)}const K="db",U="source-schemas",P="source-schema.sql",E="source-schema.meta.json",ie="index.json",$=20,p=String;export function getSourceSchemaRootDir(e){return w(e.productDir,K,e.name,U)}export async function getSourceSchemaCachePath(e,r={}){return(await resolveSourceSchemaCacheLocation(e,r)).cachePath}export async function resolveSourceSchemaCacheLocation(e,r={}){const t=r.repoState||await W(e.productDir,{fingerprints:e.testkit?.fingerprints}),c=getSourceSchemaRootDir(e),a=w(e.productDir,K,e.name,U,...t.cacheKey.split("/").map(v));return{rootDir:c,cacheDir:a,cachePath:s.join(a,P),metadataPath:s.join(a,E),indexPath:s.join(c,ie),cacheKey:t.cacheKey,cacheKind:t.kind,repoState:t}}export async function resolveSourceSchemaState(e,r={}){const t=e.testkit.database?.sourceSchema||null;if(!t)return{active:!1};const c=await resolveSourceSchemaCacheLocation(e,r),a=c.cachePath,n=o.existsSync(a);n&&A(a);const i=r.env||process.env,u=t.kind==="env"?[t.env]:[T(e.name),"TESTKIT_SCHEMA_SOURCE_DATABASE_URL"];ge(e,u);const h=X(e,[...u,"CI"],i),m=u.find(z=>{const _=h[z];return typeof _=="string"&&_.trim().length>0})||null,S=m?h[m]:void 0,f=typeof S=="string"?S.trim():null;return t.kind==="env"||!!f||n?{active:!0,cachePath:a,metadataPath:c.metadataPath,indexPath:c.indexPath,rootDir:c.rootDir,cacheDir:c.cacheDir,cacheKey:c.cacheKey,cacheKind:c.cacheKind,repoState:c.repoState,envName:m,sourceUrl:f,sourceSchema:t,cacheExists:n,refreshed:!1,ci:h.CI==="true",unavailableMode:be(t.unavailable,i)}:{active:!1,cachePath:a}}export async function prepareSourceSchemaCache(e,r={}){r.signal?.throwIfAborted();const t=await resolveSourceSchemaState(e,r);if(!t.active)return t;if(H(t)){if(fe(t))try{const a=await F(e,t,r),n={...t,refreshed:!0,cacheExists:!0,refreshInfo:a};return y(e,n,{refreshed:!0}),n}catch(a){r.signal?.throwIfAborted();const n=oe(e,t,a,r);if(n)return n;throw a}return y(e,t),r.setupRegistry?.recordCached({config:e,stage:"source-schema",kind:"database-source-schema",summary:"source schema cache hit"}),t}if(t.cacheExists&&t.unavailableMode==="warn-cache")return y(e,t),r.setupRegistry?.recordCached({config:e,stage:"source-schema",kind:"database-source-schema",summary:"source unavailable; using cached source schema"}),t;const c=t.sourceSchema.kind==="env"?t.sourceSchema.env:`${T(e.name)} or TESTKIT_SCHEMA_SOURCE_DATABASE_URL`;throw new Error(`Source schema database URL is unavailable for service "${e.name}". Set ${c}.`)}function oe(e,r,t,c={}){if(r.unavailableMode!=="warn-cache")return null;const a=ue(e,r);if(!a)return null;const n={...r,...a,cacheExists:!0,refreshed:!1,refreshError:xe(t)};return c.setupRegistry?.recordCached({config:e,stage:"source-schema",kind:"database-source-schema",summary:`source unavailable; using cached source schema: ${s.relative(e.productDir,a.cachePath)}`}),n}function ue(e,r){const t=M(e,r,{cachePath:r.cachePath,metadataPath:r.metadataPath,cacheKey:r.cacheKey,cacheKind:r.cacheKind});if(t)return t;const c=j(r.indexPath);c.sort((a,n)=>Date.parse(n.lastUsedAt||n.refreshedAt||"0")-Date.parse(a.lastUsedAt||a.refreshedAt||"0"));for(const a of c){if(!a.cachePath)continue;const n=s.resolve(e.productDir,a.cachePath),i=M(e,r,{cachePath:n,metadataPath:s.join(s.dirname(n),E),cacheKey:a.cacheKey,cacheKind:a.kind||"unknown"});if(i)return i}return null}function M(e,r,t){const c=s.resolve(t.cachePath);if(s.basename(c)!==P)return null;const a=R(e.productDir,s.dirname(c),`Service "${e.name}" source schema cache directory`),n=s.join(a,P),i=s.resolve(r.rootDir);if(n!==i&&!n.startsWith(`${i}${s.sep}`)||!o.existsSync(n))return null;const u=o.lstatSync(n);if(u.isSymbolicLink()||!u.isFile())throw new Error(`Refusing unsafe source schema cache file at ${n}`);return{cachePath:n,metadataPath:s.join(a,E),cacheDir:a,cacheKey:t.cacheKey,cacheKind:t.cacheKind,indexPath:r.indexPath,rootDir:r.rootDir}}export async function forceRefreshSourceSchemaCache(e,r,t={}){const c=await resolveSourceSchemaState(e,t);if(!c.active||!H(c)){const i=r?.active?r.envName:null,u=r?.active&&r.sourceSchema.kind==="env"?r.sourceSchema.env:null,h=i||u||"source schema env";throw new Error(`Cannot refresh source schema for service "${e.name}" because ${h} is unavailable.`)}const a=await F(e,c,t),n={...c,refreshed:!0,cacheExists:!0,refreshInfo:a};return y(e,n,{refreshed:!0}),n}export async function applySourceSchemaCache(e,r,t,c={}){if(!t?.active)return!1;if(!o.existsSync(t.cachePath))throw new Error(`Missing source schema cache for service "${e.name}": ${t.cachePath}`);const a={kind:"sql-file",path:s.relative(e.productDir,t.cachePath).split(s.sep).join("/"),cwd:null,inputs:[]};return await Y(e,"source-schema",a,0,r,c),!0}export async function verifyLocalSchemaMatchesSource(e,r,t,c={}){if(!t?.active)return{status:"disabled"};if(!t.sourceSchema.verify||c.skipSchemaSourceVerify)return c.setupRegistry?.recordCached({config:e,stage:"source-schema:verify",kind:"database-source-schema",summary:"source schema verification skipped"}),{status:"skipped"};ve(e);const a=de(t.cachePath),n=await G(e,r,{image:await c.resolveLocalSnapshotImage?.(),ownedContainer:c.ownedContainer||null,reporter:c.reporter||null,signal:c.signal});return n===a?{status:"matched"}:{status:"mismatch",diagnostics:await De(e,t,a,n)}}export function createSourceSchemaMismatchError(e,r,t){const c=[`Local schema replay does not match source schema for service "${e.name}".`,`Source cache: ${s.relative(e.productDir,r.cachePath)}`];return t?.status==="mismatch"&&c.push(`Diff: ${s.relative(e.productDir,t.diagnostics.diffPath)}`),new Error(c.join(`
2
+ `))}export async function appendSourceSchemaCacheToHash(e,r,t=null){if(!(r.testkit.database?.sourceSchema||null))return;const a=t?.cachePath||await getSourceSchemaCachePath(r),n=t?.active&&t.cacheKey?t.cacheKey:s.relative(getSourceSchemaRootDir(r),s.dirname(a));if(e.update(`source-schema-key:${n}`),e.update(`source-schema-cache:${s.relative(r.productDir,a)}`),!o.existsSync(a)){e.update(":missing");return}const i=o.statSync(a);e.update(`:${i.size.toString()}:${i.mtimeMs.toString()}`),e.update(o.readFileSync(a))}async function F(e,r,t={}){const c=t.setupRegistry,a=c?.start({config:e,stage:"source-schema:refresh",kind:"database-source-schema",summary:`source schema refresh: ${s.relative(e.productDir,r.cachePath)}`});try{const n=await te(re(r.cachePath),async({waited:u,requestedAt:h})=>{const m=le(r,h);if(m)return{reusedExistingRefresh:!0,waitedForLock:u,metadata:m};const S=await he(e,r.sourceUrl,r.cachePath,{reporter:t.reporter||null,logRecord:a?._logRecord||null,env:t.env||process.env,signal:t.signal}),f=me(e,r,S);return pe(r.metadataPath,f),{reusedExistingRefresh:!1,waitedForLock:u,metadata:f}},{signal:t.signal}),i=a&&c?c.finish(a,{status:"passed",summary:`source schema refresh: ${s.relative(e.productDir,r.cachePath)}`}):null;return i&&t.reporter?.setupOperationFinished?.(i),n}catch(n){const i=a?c?.finish(a,{status:"failed",summary:`source schema refresh: ${s.relative(e.productDir,r.cachePath)}`,error:_e(n)}):null;throw i&&t.reporter?.setupOperationFinished?.(i),n}}async function he(e,r,t,c={}){const a=ae(r);o.mkdirSync(s.dirname(t),{recursive:!0});const n=ce(".source-schema-",{parentDir:s.dirname(t)}),i=s.join(n,s.basename(t));try{const u=await Q(e,a.dumpUrl,{env:c.env||process.env,pgApplicationName:b(e.name),signal:c.signal});return await Z(e,i,a.dumpUrl,{...c,image:u,pgApplicationName:b(e.name)}),o.renameSync(i,t),{resolution:a,pgDump:{args:["--schema-only","--no-owner","--no-privileges"],applicationName:b(e.name),image:u}}}finally{ne(n)}}function me(e,r,t){return{refreshedAt:new Date().toISOString(),serviceName:e.name,envName:r.envName,cacheKey:r.cacheKey,cacheKind:r.cacheKind,cachePath:s.relative(e.productDir,r.cachePath),repo:C(r.repoState),sourceUrl:t.resolution.metadata,pgDump:t.pgDump}}function le(e,r){if(!o.existsSync(e.cachePath))return null;const t=g(e.metadataPath);if(!ke(t))return null;const c=Date.parse(t.refreshedAt);return!Number.isFinite(c)||c<r?null:t}function b(e){return`testkit:schema-source:${e.replace(/[^a-zA-Z0-9._:-]+/g,"-").slice(0,24)||"service"}`}function de(e){return ee(o.readFileSync(e,"utf8"))}function fe(e){if(!e.cacheExists)return!0;if(e.sourceSchema.refresh.mode==="auto")return e.ci;if(e.sourceSchema.refresh.mode==="ttl"){const r=x(e.metadataPath),t=r?Date.parse(r):0;return!Number.isFinite(t)||t<=0?!0:Date.now()-t>=e.sourceSchema.refresh.ttlSeconds*1e3}return!0}function y(e,r,t={}){if(!r?.active)return;const c=new Date().toISOString(),a=j(r.indexPath).filter(u=>u.cacheKey!==r.cacheKey),n=x(r.metadataPath);a.push({cacheKey:r.cacheKey,kind:r.cacheKind,commitSha:r.repoState.commitSha||null,branch:r.repoState.branch||null,detached:r.repoState.detached,dirty:r.repoState.dirty,dirtyFingerprint:r.repoState.dirtyFingerprint||null,contentFingerprint:r.repoState.contentFingerprint||null,remoteUrl:r.repoState.remoteUrl||null,repository:r.repoState.repository,cachePath:s.relative(e.productDir,r.cachePath),refreshedAt:t.refreshed?c:n,lastUsedAt:c});const i=Se(e,r,a);N(r.indexPath,{version:1,entries:i})}function Se(e,r,t){if(t.length<=$)return t;const c=[...t].sort((n,i)=>Date.parse(i.lastUsedAt||"0")-Date.parse(n.lastUsedAt||"0")),a=new Set(c.slice(0,$).map(n=>n.cacheKey));a.add(r.cacheKey);for(const n of t){if(a.has(n.cacheKey))continue;const i=n.cachePath?s.join(e.productDir,n.cachePath):null,u=i?s.resolve(i):null,h=s.resolve(r.rootDir);if(!i||!u||u!==h&&!u.startsWith(`${h}${s.sep}`))continue;const m=R(e.productDir,s.dirname(i),`Service "${e.name}" pruned source schema cache directory`);o.rmSync(m,{recursive:!0,force:!0})}return t.filter(n=>a.has(n.cacheKey))}function pe(e,r){I(e,r)}function N(e,r){I(e,r)}function I(e,r){o.mkdirSync(s.dirname(e),{recursive:!0}),A(e),o.writeFileSync(e,Ae(r))}function g(e){if(!e||!o.existsSync(e))return null;A(e);try{return JSON.parse(o.readFileSync(e,"utf8"))}catch{return null}}function L(e){return w(e.productDir,"results","schema")}function A(e){if(!o.existsSync(e))return;const r=o.lstatSync(e);if(r.isSymbolicLink()||!r.isFile())throw new Error(`Refusing unsafe source schema state file at ${e}`)}function ye(e){return`${v(e.runtimeLabel||e.name)}__${v(e.name)}`}function ve(e){const r=L(e);if(!o.existsSync(r))return;const t=v(e.name);for(const c of o.readdirSync(r))we(c,t)&&o.rmSync(s.join(r,c),{force:!0})}function we(e,r){for(const t of["__source-schema.sql","__local-replay-schema.sql","__schema.diff","__schema.meta.json"])if(e.endsWith(t))return e.slice(0,-t.length).endsWith(`__${r}`);return!1}async function De(e,r,t,c){const a=L(e);o.mkdirSync(a,{recursive:!0});const n=ye(e),i=s.join(a,`${n}__source-schema.sql`),u=s.join(a,`${n}__local-replay-schema.sql`),h=s.join(a,`${n}__schema.diff`),m=s.join(a,`${n}__schema.meta.json`);return o.writeFileSync(i,t),o.writeFileSync(u,c),o.writeFileSync(h,await Ee(i,u)),N(m,Pe(e,r)),{sourceCachePath:r.cachePath,sourcePath:i,localPath:u,diffPath:h,metaPath:m}}function Pe(e,r){const t=x(r.metadataPath);return{version:1,createdAt:new Date().toISOString(),serviceName:e.name,runtimeLabel:e.runtimeLabel||e.name,sourceSchema:{envName:r.envName||null,cacheKey:r.cacheKey||null,cacheKind:r.cacheKind||null,cachePath:s.relative(e.productDir,r.cachePath),refreshedAt:t},repo:C(r.repoState)}}function Ee(e,r){return new Promise(t=>{t(buildUnifiedDiffText(o.readFileSync(e,"utf8"),o.readFileSync(r,"utf8"),e,r))})}export function buildUnifiedDiffText(e,r,t="source",c="target"){if(e===r)return"";const{createTwoFilesPatch:a}=se();return a(t,c,p(e),p(r),"","",{context:3})}function be(e,r){return e==="fail"||e==="warn-cache"?e:r.CI==="true"?"fail":"warn-cache"}function T(e){return`TESTKIT_${e.toUpperCase().replace(/[^A-Z0-9]+/g,"_")}_SCHEMA_SOURCE_DATABASE_URL`}function ge(e,r){const t={...e.testkit.serviceEnv,...e.testkit.local?.env};for(const c of r){const a=t[c];if(typeof a!="string")continue;const n=V(a).find(i=>(i.serviceName??e.name)===e.name);if(n)throw new Error(`Service "${e.name}" env ${c} uses ${n.placeholder}, which is the database the source schema verifies. Point it at another service's database or an external database URL.`)}}function v(e){return e.trim().toLowerCase().replace(/[^a-z0-9._-]+/g,"-").replace(/^-+|-+$/g,"")||"schema"}function j(e){const r=g(e);return!d(r)||!Array.isArray(r.entries)?[]:r.entries.filter(Ce)}function x(e){const r=g(e);return d(r)&&typeof r.refreshedAt=="string"?r.refreshedAt:null}function Ae(e){return`${p(JSON.stringify(e,null,2))}
3
+ `}function xe(e){const r=O(e,"message");return!!r?r:p(e)}function _e(e){const r=O(e,"message");return!!r?r:e}function O(e,r){if(!(e===null||typeof e!="object"&&typeof e!="function"))return Reflect.get(e,r)}function H(e){return!!e.sourceUrl}function Ce(e){return!d(e)||typeof e.cacheKey!="string"||e.cacheKey.length===0?!1:l(e.branch)&&l(e.cachePath)&&l(e.commitSha)&&l(e.contentFingerprint)&&B(e.detached)&&B(e.dirty)&&l(e.dirtyFingerprint)&&l(e.kind)&&l(e.lastUsedAt)&&l(e.refreshedAt)&&l(e.remoteUrl)&&Re(e.repository)}function Re(e){return e==null||d(e)&&typeof e.host=="string"&&typeof e.owner=="string"&&typeof e.name=="string"}function ke(e){return d(e)&&typeof e.refreshedAt=="string"&&(e.sourceUrl===void 0||Ke(e.sourceUrl))}function Ke(e){return d(e)&&typeof e.originalHost=="string"&&q(e.originalClassification)&&typeof e.resolvedHost=="string"&&q(e.resolvedClassification)&&typeof e.rewritten=="boolean"}function q(e){return e==="neon-pooler"||e==="neon-direct"||e==="unsupported-pooler"||e==="unknown-direct"}function Ue(e){return e===null||typeof e=="string"}function l(e){return e===void 0||Ue(e)}function B(e){return e===void 0||typeof e=="boolean"}function d(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)}
@@ -3,7 +3,7 @@
3
3
  "entrypoint": "dist/process/process.js",
4
4
  "sourceSha256": "a54f01864b603cee568b2945390dff35d578e07f6d26b5add06014169c3d17b0",
5
5
  "bundleSha256": "62f76cf9fd019f6c552bfde2787f058917caf279670a62e7a6bfa12be34cb4b1",
6
- "packageLockSha256": "55c59c3d32e2787abf76da0427c095c11d2410d343c9294120570d3d4a8b2bd4",
6
+ "packageLockSha256": "59332a4b9bf51d0bedee38025690d15fdf490151211ea06c4109f0896796709f",
7
7
  "esbuildVersion": "0.28.1",
8
8
  "options": {
9
9
  "format": "esm",
@@ -21,6 +21,10 @@ export interface DatabaseTemplateBindings {
21
21
  user: string;
22
22
  }
23
23
  export type DatabaseTemplateBindingsReader = (stateDir: string) => DatabaseTemplateBindings | null;
24
+ export interface DatabasePlaceholder {
25
+ placeholder: string;
26
+ serviceName: null | string;
27
+ }
24
28
  export interface ExecutionTemplateContextValues {
25
29
  [key: string]: unknown;
26
30
  baseUrlByService?: StringMap;
@@ -70,9 +74,11 @@ export interface TemplateResolutionContext extends ExecutionTemplateContextValue
70
74
  export declare function buildExecutionEnv(config: Readonly<ExecutionEnvironmentConfig>, extraEnv?: Readonly<NodeJS.ProcessEnv> | null, processEnv?: Readonly<NodeJS.ProcessEnv> | null, options?: Readonly<BuildExecutionEnvironmentOptions>): NodeJS.ProcessEnv;
71
75
  export declare function buildTemplateExecutionEnv(config: Readonly<ExecutionEnvironmentConfig>, extraEnv?: Readonly<NodeJS.ProcessEnv> | null, processEnv?: Readonly<NodeJS.ProcessEnv> | null, options?: Readonly<BuildExecutionEnvironmentOptions>): NodeJS.ProcessEnv;
72
76
  export declare function buildTaskExecutionEnv(config: Readonly<ExecutionEnvironmentConfig>, lease: Readonly<ExecutionEnvironmentLease> | null, extraEnv?: Readonly<NodeJS.ProcessEnv> | null, processEnv?: Readonly<NodeJS.ProcessEnv> | null, options?: Readonly<BuildExecutionEnvironmentOptions>): NodeJS.ProcessEnv;
77
+ export declare function resolveExecutionEnvValues(config: Readonly<ExecutionEnvironmentConfig>, names: readonly string[], processEnv?: Readonly<NodeJS.ProcessEnv> | null): NodeJS.ProcessEnv;
73
78
  export declare function buildTemplateContext(config: Readonly<ExecutionEnvironmentConfig>, lease: Readonly<ExecutionEnvironmentLease> | null): TemplateResolutionContext;
74
79
  export declare function finalizeString(value: string, context: TemplateResolutionContext): string;
75
80
  export declare function finalizeSerializable(value: unknown, context: TemplateResolutionContext): unknown;
76
81
  export declare function resolveTemplateString(value: string, context: TemplateResolutionContext): string;
77
82
  export declare function resolveTemplateString(value: unknown, context: TemplateResolutionContext): unknown;
83
+ export declare function findDatabasePlaceholders(value: string): DatabasePlaceholder[];
78
84
  export {};
@@ -1 +1 @@
1
- import u from"path";import{joinPathList as m,packageBinPath as b,shellQuote as f}from"../process/host/platform.js";export function buildExecutionEnv(r,e={},t=process.env,a={}){return o(r,null,e,t,a)}export function buildTemplateExecutionEnv(r,e={},t=process.env,a={}){return o(r,null,e,t,{...a,omitRuntimeDatabaseBindings:!0})}export function buildTaskExecutionEnv(r,e,t={},a=process.env,s={}){return o(r,e,t,a,s)}function o(r,e,t,a,s={}){const i=s.inheritProcessEnv===!1?{}:{...a};delete i.DATABASE_URL;const n=buildTemplateContext(r,e),p=s.omitRuntimeDatabaseBindings?d(r.testkit.serviceEnv||{}):r.testkit.serviceEnv||{},v=s.omitRuntimeDatabaseBindings?d(r.testkit.local?.env||{}):r.testkit.local?.env||{};return{...i,...c(p,n),...c(v,n),...c(t,n),...s.includeTestkitControls===!1?{}:{TESTKIT_ACTIVE:"1",TESTKIT_MODE:s.mode||r.testkit.mode||"test",...r.runtimeId?{TESTKIT_RUNTIME_ID:String(r.runtimeId)}:{},...e?.leaseId?{TESTKIT_LEASE_ID:String(e.leaseId)}:{},...e?.leaseDir?{TESTKIT_LEASE_DIR:e.leaseDir}:{}}}}export function buildTemplateContext(r,e){const t=r.testkit.templateContext||{};return{...t,runtimeId:r.runtimeId||t.runtimeId||null,runtimeLabel:r.runtimeLabel||t.runtimeLabel||null,productDir:r.productDir||t.productDir||null,serviceName:r.name,serviceStateDir:r.stateDir||t.serviceStateDir||null,prepareDir:r.testkit.prepareDir||t.prepareDir||null,leaseId:e?.leaseId||null,leaseDir:e?.leaseDir||null}}export function finalizeString(r,e){let t=resolveTemplateString(r,e);for(const[a,s]of e.urlMappings||[])a&&s&&a!==s&&(t=t.split(a).join(s));return t}export function finalizeSerializable(r,e){return typeof r=="string"?finalizeString(r,e):Array.isArray(r)?r.map(t=>finalizeSerializable(t,e)):r===null||typeof r!="object"?r:Object.fromEntries(Object.entries(r).map(([t,a])=>[t,finalizeSerializable(a,e)]))}export function resolveTemplateString(r,e){return typeof r!="string"?r:r.replace(/\{([a-zA-Z]+)(?::([^}]+))?\}/g,(t,a,s)=>{switch(a){case"runtime":case"runtimeId":return String(e.runtimeId);case"service":return e.serviceName;case"stateDir":return String(e.serviceStateDir);case"productDir":return e.productDir||"";case"bin":{const i=u.resolve(e.productDir||process.cwd(),e.serviceCwd||".");return f(b(i,s))}case"pathList":{const i=l(s||"").split(",").map(n=>n.trim()).filter(Boolean).map(n=>u.resolve(e.productDir||process.cwd(),n));return m(i)}case"prepareDir":return e.prepareDir||"";case"lease":return e.leaseId?String(e.leaseId):"";case"leaseDir":return e.leaseDir||"";case"port":{const i=s||e.serviceName,n=e.portMap?.get(i);if(!n)throw new Error(`Unknown port placeholder for service "${i}"`);return String(n)}case"baseUrl":{const i=s||e.serviceName,n=e.baseUrlByService?.get(i);if(!n)throw new Error(`Unknown baseUrl placeholder for service "${i}"`);return n}case"readyUrl":{const i=s||e.serviceName,n=e.readyUrlByService?.get(i);if(!n)throw new Error(`Unknown readyUrl placeholder for service "${i}"`);return n}case"publicHost":return e.publicHost||"127.0.0.1";case"publicBaseUrl":{const i=s||e.serviceName,n=e.publicBaseUrlByService?.get(i)||e.baseUrlByService?.get(i);if(!n)throw new Error(`Unknown publicBaseUrl placeholder for service "${i}"`);return n}case"privateBaseUrl":{const i=s||e.serviceName,n=e.privateBaseUrlByService?.get(i)||e.baseUrlByService?.get(i);if(!n)throw new Error(`Unknown privateBaseUrl placeholder for service "${i}"`);return n}case"serviceContextUrl":{const i=s||e.serviceName,n=e.serviceContextUrlByService?.get(i)||e.baseUrlByService?.get(i);if(!n)throw new Error(`Unknown serviceContextUrl placeholder for service "${i}"`);return n}case"dbUrl":case"dbHost":case"dbPort":case"dbName":case"dbUser":case"dbPassword":case"dbSsl":{const i=s||e.serviceName;return E(a,i,e)}default:throw new Error(`Unsupported template token "{${a}${s?`:${s}`:""}}"`)}})}function c(r,e){return Object.fromEntries(Object.entries(r||{}).map(([t,a])=>[t,typeof a=="string"?finalizeString(a,e):a]))}function d(r={}){return Object.fromEntries(Object.entries(r).filter(e=>{const t=e[1];return typeof t!="string"?!0:!/\{db(?:Url|Host|Port|Name|User|Password|Ssl)(?::[^}]+)?\}/.test(t)}))}function E(r,e,t){const a=t.stateDirByService?.get(e),s=t.databaseBindingsByService?t.databaseBindingsByService.get(e):a?t.databaseBindingsReader?.(a):null;if(!s)throw new Error(`Unknown database placeholder for service "${e}"`);switch(r){case"dbUrl":return s.url;case"dbHost":return s.host;case"dbPort":return String(s.port);case"dbName":return s.database;case"dbUser":return s.user;case"dbPassword":return s.password;case"dbSsl":return w(s)}}function w(r){const e=r.sslMode,t=l(e||"").trim();return t&&!U(t)?t:S(r.sslMode)?"1":"0"}function S(r){const e=l(r||"").trim().toLowerCase();return!!(e&&!["0","false","no","off","disable","disabled"].includes(e))}function U(r){return["0","1","true","false","yes","no","on","off","disable","disabled","require","verify-ca","verify-full"].includes(l(r||"").trim().toLowerCase())}function l(r){const e=Reflect.apply(String,void 0,[r]);if(typeof e!="string")throw new Error("String conversion returned an invalid result");return e}
1
+ import v from"path";import{joinPathList as m,packageBinPath as E,shellQuote as w}from"../process/host/platform.js";export function buildExecutionEnv(r,e={},t=process.env,n={}){return o(r,null,e,t,n)}export function buildTemplateExecutionEnv(r,e={},t=process.env,n={}){return o(r,null,e,t,{...n,omitRuntimeDatabaseBindings:!0})}export function buildTaskExecutionEnv(r,e,t={},n=process.env,i={}){return o(r,e,t,n,i)}export function resolveExecutionEnvValues(r,e,t=process.env){return o(r,null,{},t,{includeTestkitControls:!1},new Set(e))}function o(r,e,t,n,i={},s=null){const a=c(i.inheritProcessEnv===!1?{}:n,s,!1);delete a.DATABASE_URL;const u=buildTemplateContext(r,e),p=i.omitRuntimeDatabaseBindings===!0,f=c(r.testkit.serviceEnv,s,p),b=c(r.testkit.local?.env,s,p);return{...a,...d(f,u),...d(b,u),...d(c(t,s,!1),u),...i.includeTestkitControls===!1?{}:{TESTKIT_ACTIVE:"1",TESTKIT_MODE:i.mode||r.testkit.mode||"test",...r.runtimeId?{TESTKIT_RUNTIME_ID:String(r.runtimeId)}:{},...e?.leaseId?{TESTKIT_LEASE_ID:String(e.leaseId)}:{},...e?.leaseDir?{TESTKIT_LEASE_DIR:e.leaseDir}:{}}}}export function buildTemplateContext(r,e){const t=r.testkit.templateContext||{};return{...t,runtimeId:r.runtimeId||t.runtimeId||null,runtimeLabel:r.runtimeLabel||t.runtimeLabel||null,productDir:r.productDir||t.productDir||null,serviceName:r.name,serviceStateDir:r.stateDir||t.serviceStateDir||null,prepareDir:r.testkit.prepareDir||t.prepareDir||null,leaseId:e?.leaseId||null,leaseDir:e?.leaseDir||null}}export function finalizeString(r,e){let t=resolveTemplateString(r,e);for(const[n,i]of e.urlMappings||[])n&&i&&n!==i&&(t=t.split(n).join(i));return t}export function finalizeSerializable(r,e){return typeof r=="string"?finalizeString(r,e):Array.isArray(r)?r.map(t=>finalizeSerializable(t,e)):r===null||typeof r!="object"?r:Object.fromEntries(Object.entries(r).map(([t,n])=>[t,finalizeSerializable(n,e)]))}export function resolveTemplateString(r,e){return typeof r!="string"?r:r.replace(/\{([a-zA-Z]+)(?::([^}]+))?\}/g,(t,n,i)=>{switch(n){case"runtime":case"runtimeId":return String(e.runtimeId);case"service":return e.serviceName;case"stateDir":return String(e.serviceStateDir);case"productDir":return e.productDir||"";case"bin":{const s=v.resolve(e.productDir||process.cwd(),e.serviceCwd||".");return w(E(s,i))}case"pathList":{const s=l(i||"").split(",").map(a=>a.trim()).filter(Boolean).map(a=>v.resolve(e.productDir||process.cwd(),a));return m(s)}case"prepareDir":return e.prepareDir||"";case"lease":return e.leaseId?String(e.leaseId):"";case"leaseDir":return e.leaseDir||"";case"port":{const s=i||e.serviceName,a=e.portMap?.get(s);if(!a)throw new Error(`Unknown port placeholder for service "${s}"`);return String(a)}case"baseUrl":{const s=i||e.serviceName,a=e.baseUrlByService?.get(s);if(!a)throw new Error(`Unknown baseUrl placeholder for service "${s}"`);return a}case"readyUrl":{const s=i||e.serviceName,a=e.readyUrlByService?.get(s);if(!a)throw new Error(`Unknown readyUrl placeholder for service "${s}"`);return a}case"publicHost":return e.publicHost||"127.0.0.1";case"publicBaseUrl":{const s=i||e.serviceName,a=e.publicBaseUrlByService?.get(s)||e.baseUrlByService?.get(s);if(!a)throw new Error(`Unknown publicBaseUrl placeholder for service "${s}"`);return a}case"privateBaseUrl":{const s=i||e.serviceName,a=e.privateBaseUrlByService?.get(s)||e.baseUrlByService?.get(s);if(!a)throw new Error(`Unknown privateBaseUrl placeholder for service "${s}"`);return a}case"serviceContextUrl":{const s=i||e.serviceName,a=e.serviceContextUrlByService?.get(s)||e.baseUrlByService?.get(s);if(!a)throw new Error(`Unknown serviceContextUrl placeholder for service "${s}"`);return a}case"dbUrl":case"dbHost":case"dbPort":case"dbName":case"dbUser":case"dbPassword":case"dbSsl":{const s=i||e.serviceName;return U(n,t,s,e)}default:throw new Error(`Unsupported template token "{${n}${i?`:${i}`:""}}"`)}})}function d(r,e){return Object.fromEntries(Object.entries(r||{}).map(([t,n])=>[t,typeof n=="string"?S(t,n,e):n]))}function S(r,e,t){try{return finalizeString(e,t)}catch(n){const i=n instanceof Error?n.message:l(n);throw new Error(`Service "${t.serviceName}" env ${r}: ${i}`,{cause:n})}}function c(r,e,t){return Object.fromEntries(Object.entries(r||{}).filter(([n,i])=>e&&!e.has(n)?!1:!(t&&typeof i=="string"&&findDatabasePlaceholders(i).length>0)))}export function findDatabasePlaceholders(r){return[...r.matchAll(/\{db(?:Url|Host|Port|Name|User|Password|Ssl)(?::([^}]+))?\}/g)].map(e=>({placeholder:e[0],serviceName:e.at(1)??null}))}function U(r,e,t,n){const i=n.stateDirByService?.get(t),s=n.databaseBindingsByService?n.databaseBindingsByService.get(t):i?n.databaseBindingsReader?.(i):null;if(!s)throw new Error(`Cannot resolve ${e}: service "${t}" has no database binding in this context`);switch(r){case"dbUrl":return s.url;case"dbHost":return s.host;case"dbPort":return String(s.port);case"dbName":return s.database;case"dbUser":return s.user;case"dbPassword":return s.password;case"dbSsl":return D(s)}}function D(r){const e=r.sslMode,t=l(e||"").trim();return t&&!B(t)?t:h(r.sslMode)?"1":"0"}function h(r){const e=l(r||"").trim().toLowerCase();return!!(e&&!["0","false","no","off","disable","disabled"].includes(e))}function B(r){return["0","1","true","false","yes","no","on","off","disable","disabled","require","verify-ca","verify-full"].includes(l(r||"").trim().toLowerCase())}function l(r){const e=Reflect.apply(String,void 0,[r]);if(typeof e!="string")throw new Error("String conversion returned an invalid result");return e}
@@ -1,2 +1,2 @@
1
- export { buildExecutionEnv, buildTaskExecutionEnv, buildTemplateContext, buildTemplateExecutionEnv, finalizeSerializable, finalizeString, resolveTemplateString, type BuildExecutionEnvironmentOptions, type DatabaseTemplateBindings, type DatabaseTemplateBindingsReader, type ExecutionEnvironmentConfig, type ExecutionEnvironmentLease, type ExecutionEnvironmentLocalConfig, type ExecutionEnvironmentTestkitConfig, type ExecutionTemplateContextValues, type RuntimeUrlMapping, type TemplateResolutionContext, } from "./environment.js";
1
+ export { buildExecutionEnv, buildTaskExecutionEnv, buildTemplateContext, buildTemplateExecutionEnv, finalizeSerializable, finalizeString, findDatabasePlaceholders, resolveExecutionEnvValues, resolveTemplateString, type BuildExecutionEnvironmentOptions, type DatabasePlaceholder, type DatabaseTemplateBindings, type DatabaseTemplateBindingsReader, type ExecutionEnvironmentConfig, type ExecutionEnvironmentLease, type ExecutionEnvironmentLocalConfig, type ExecutionEnvironmentTestkitConfig, type ExecutionTemplateContextValues, type RuntimeUrlMapping, type TemplateResolutionContext, } from "./environment.js";
2
2
  export { collectConfiguredInputs, configuredCommandInvocation, parseModuleSpecifier, resolveConfiguredCwd, resolveConfiguredPath, type ConfiguredCommandInvocation, type ConfiguredStepReporter, type ConfiguredStepServiceConfig, } from "./run.js";
@@ -1 +1 @@
1
- export{buildExecutionEnv,buildTaskExecutionEnv,buildTemplateContext,buildTemplateExecutionEnv,finalizeSerializable,finalizeString,resolveTemplateString}from"./environment.js";export{collectConfiguredInputs,configuredCommandInvocation,parseModuleSpecifier,resolveConfiguredCwd,resolveConfiguredPath}from"./run.js";
1
+ export{buildExecutionEnv,buildTaskExecutionEnv,buildTemplateContext,buildTemplateExecutionEnv,finalizeSerializable,finalizeString,findDatabasePlaceholders,resolveExecutionEnvValues,resolveTemplateString}from"./environment.js";export{collectConfiguredInputs,configuredCommandInvocation,parseModuleSpecifier,resolveConfiguredCwd,resolveConfiguredPath}from"./run.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gfdelarue/testkit",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Test orchestration platform for product acceptance and QA workflows",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -333,11 +333,63 @@ Template tokens are supported in strings:
333
333
  - `{port}` / `{port:serviceName}`
334
334
  - `{baseUrl}` / `{baseUrl:serviceName}`
335
335
  - `{readyUrl}` / `{readyUrl:serviceName}`
336
+ - `{dbUrl}`, `{dbHost}`, `{dbPort}`, `{dbName}`, `{dbUser}`, `{dbPassword}`,
337
+ `{dbSsl}`, each with an optional `:serviceName` (env values only, see
338
+ [`env`](#env))
336
339
 
337
340
  ### `dependsOn`
338
341
 
339
342
  Runtime dependencies for service startup order.
340
343
 
344
+ ### `env`
345
+
346
+ Declared environment values for the service process and every task that targets
347
+ the service. Plain services take a string record. `app.node(...)` and
348
+ `app.next(...)` take `{ values, databases }` and place the result under
349
+ `local.env`; `local.env` overrides `env` when both set a name.
350
+
351
+ Values support the template tokens listed under [`local`](#local), including
352
+ the database tokens:
353
+
354
+ | Token | Value |
355
+ | -------------- | --------------------------------------- |
356
+ | `{dbUrl}` | connection URL |
357
+ | `{dbHost}` | host |
358
+ | `{dbPort}` | host port |
359
+ | `{dbName}` | database name |
360
+ | `{dbUser}` | user |
361
+ | `{dbPassword}` | password |
362
+ | `{dbSsl}` | `1` when the binding uses SSL, else `0` |
363
+
364
+ The bare form reads the service's own database, which must be declared under
365
+ `database`. `{dbUrl:api}` reads the database of service `api`, which must be
366
+ listed in `dependsOn` so it is provisioned first. Testkit resolves these tokens
367
+ after provisioning, when it runs `runtime.prepare` steps, starts the service
368
+ process, and builds each task environment, from the database provisioned for
369
+ that run. A repeated run never sees a previous run's connection details.
370
+
371
+ Database tokens resolve only in `env` and `local.env` values. Testkit rejects
372
+ them at config load in `local.start`, `local.cwd`, `runtime.prepare`, and
373
+ `database.template` strings, because it resolves those strings while laying out
374
+ the runtime, before any database exists. Commands read `DATABASE_URL` or a
375
+ declared env entry at run time instead.
376
+
377
+ Source-schema discovery fails when the env entry it reads as the source URL
378
+ points at the service's own database. A source schema has to come from a
379
+ database other than the one it verifies.
380
+
381
+ `env.databases` expands a binding into prefixed entries:
382
+
383
+ ```ts
384
+ env: {
385
+ databases: { api: { prefix: "API", service: "api" } },
386
+ }
387
+ ```
388
+
389
+ produces `API_DATABASE_HOST`, `API_DATABASE_PORT`, `API_DATABASE_NAME`,
390
+ `API_DATABASE_USER`, `API_DATABASE_PASSWORD`, and `API_DATABASE_SSL`, each set
391
+ to the matching `{db*:api}` token. `env.values` entries override expanded names.
392
+
341
393
  ### `envFile` / `envFiles`
342
394
 
343
395
  Optional extra env files loaded into the declared service/task environment for