@fjall/schema-gate 19.0.0 → 21.0.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/.build-source-hash +14 -8
- package/dist/.minified +1 -1
- package/dist/bin.d.ts +8 -0
- package/dist/bin.js +2 -2
- package/dist/classifyPgError.d.ts +26 -0
- package/dist/classifyPgError.js +1 -0
- package/dist/constants.d.ts +36 -0
- package/dist/constants.js +1 -0
- package/dist/errorMessage.d.ts +8 -0
- package/dist/errorMessage.js +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/outcomes.d.ts +52 -0
- package/dist/outcomes.js +1 -0
- package/dist/reduceOutcomes.d.ts +19 -0
- package/dist/reduceOutcomes.js +1 -0
- package/dist/runSchemaGate.d.ts +16 -14
- package/dist/runSchemaGate.js +1 -1
- package/dist/types.d.ts +45 -27
- package/dist/types.js +1 -1
- package/dist/withRetry.d.ts +48 -0
- package/dist/withRetry.js +1 -0
- package/package.json +3 -3
package/dist/.build-source-hash
CHANGED
|
@@ -9,16 +9,22 @@
|
|
|
9
9
|
"package.json",
|
|
10
10
|
"tsconfig.json"
|
|
11
11
|
],
|
|
12
|
-
"hash": "
|
|
12
|
+
"hash": "ccd9ba61c3e35820153215a44b56e257bcc0a58f5dcb6d049eb446f15177a3d7",
|
|
13
13
|
"files": {
|
|
14
|
-
"src/bin.ts": "
|
|
15
|
-
"src/
|
|
16
|
-
"src/
|
|
17
|
-
"src/
|
|
14
|
+
"src/bin.ts": "220357c51746d4bc",
|
|
15
|
+
"src/classifyPgError.ts": "d89aa698e2c2d524",
|
|
16
|
+
"src/constants.ts": "7f79df8a6c9f56e6",
|
|
17
|
+
"src/errorMessage.ts": "b1a2b894374a5401",
|
|
18
|
+
"src/index.ts": "ccef506f884cbdb1",
|
|
19
|
+
"src/outcomes.ts": "3f5b42d3e1376dc1",
|
|
20
|
+
"src/reduceOutcomes.ts": "9f55c509577e0dc9",
|
|
21
|
+
"src/runSchemaGate.ts": "2efd6d45e4224d38",
|
|
22
|
+
"src/types.ts": "917f272ff8fc052f",
|
|
23
|
+
"src/withRetry.ts": "855bb351c8dab0fd",
|
|
18
24
|
"../scripts/minify-dist.mjs": "6b2e4b0df8aec601",
|
|
19
|
-
"package.json": "
|
|
25
|
+
"package.json": "497608d39cd2bcc6",
|
|
20
26
|
"tsconfig.json": "8a11f0e3fce6a36a"
|
|
21
27
|
},
|
|
22
|
-
"outputHash": "
|
|
23
|
-
"outputFileCount":
|
|
28
|
+
"outputHash": "24e376093040f3cbf45b3eb197de2735be62b3980133b59658c29b508bb1043f",
|
|
29
|
+
"outputFileCount": 21
|
|
24
30
|
}
|
package/dist/.minified
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
10 files minified at 2026-08-26T08:32:42.693Z
|
package/dist/bin.d.ts
CHANGED
|
@@ -5,5 +5,13 @@
|
|
|
5
5
|
* code — the dependent app containers' `dependsOn: SUCCESS` turns any
|
|
6
6
|
* non-zero exit into a pre-RUNNING task stop the deployment circuit breaker
|
|
7
7
|
* can count.
|
|
8
|
+
*
|
|
9
|
+
* The ClickHouse half authenticates as `fjall_schema_gate` — a dedicated
|
|
10
|
+
* read-only identity (`readonly=2`, `SELECT` on the audit table only, no
|
|
11
|
+
* per-user concurrency cap) — never as the schema admin the migration task
|
|
12
|
+
* and maintenance sidecars use. The retry budget is derived from the
|
|
13
|
+
* construct's ECS `startTimeout` (`SCHEMA_GATE_START_TIMEOUT_SECONDS`): the
|
|
14
|
+
* run must end with a verdict inside that window, so the budget cannot be a
|
|
15
|
+
* number chosen here independently of it.
|
|
8
16
|
*/
|
|
9
17
|
export {};
|
package/dist/bin.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
3
|
-
`)}
|
|
2
|
+
var f=Object.defineProperty;var a=(e,r)=>f(e,"name",{value:r,configurable:!0});import{createFjallClickHouseClient as l}from"@fjall/clickhouse";import{getErrorMessage as o,maskSensitiveOutput as i}from"@fjall/util";import{sleepAbortable as d}from"@fjall/util/migration";import w from"pg";import{DB_TIMEOUT_MS as s,LIVENESS_PING_TIMEOUT_MS as g}from"./constants.js";import{runSchemaGate as S}from"./runSchemaGate.js";import{SCHEMA_GATE_EXIT as h}from"./types.js";const{Client:C}=w,_="FjallSchemaGate";function n(e,r,t={}){process.stdout.write(`${JSON.stringify({timestamp:new Date().toISOString(),level:e,namespace:_,message:r,...t})}
|
|
3
|
+
`)}a(n,"log");function u(e,r){return new C({connectionString:e,connectionTimeoutMillis:s,query_timeout:s,statement_timeout:s,...r?{ssl:{rejectUnauthorized:!1}}:{}})}a(u,"buildPgClient");const T={async connectPg(e){const r=u(e,!0);try{return await r.connect(),r}catch(t){if(!/does not support SSL/i.test(o(t)))throw t;await r.end().catch(p=>{n("warn","Postgres TLS probe cleanup failed",{error:i(o(p))})}),n("info","Server does not support TLS \u2014 connecting in plaintext");const c=u(e,!1);return await c.connect(),c}},createChClient(e){return l({url:e.url,username:e.username,password:e.password,...e.caCert!==void 0?{caCert:e.caCert}:{},request_timeout:s})},async pingCh(e){let r;try{r=l({url:e.url,...e.caCert!==void 0?{caCert:e.caCert}:{},request_timeout:g});const t=await r.ping({select:!1});return t.success||n("warn","Liveness ping failed",{error:i(o(t.error))}),t.success}catch(t){return n("warn","Liveness ping threw",{error:i(o(t))}),!1}finally{r!==void 0&&await r.close().catch(t=>{n("warn","Liveness ping client close failed",{error:i(o(t))})})}},sleep:d,random:Math.random,now:Date.now,log:n},m=new AbortController;for(const[e,r]of[["SIGTERM",143],["SIGINT",130]])process.on(e,()=>{n("warn",`Received ${e} \u2014 exiting without a verdict`),m.abort(),process.exit(r)});S(process.env,T,m.signal).then(e=>{process.exit(e)}).catch(e=>{n("error","Gate crashed",{error:i(o(e))}),process.exit(h.configError)});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GateErrorClassification } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Postgres SQLSTATE codes the gate branches on (`pg`'s `DatabaseError.code`).
|
|
4
|
+
* Single source for every class set below.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PG_ERROR_CODES: {
|
|
7
|
+
readonly UNDEFINED_TABLE: "42P01";
|
|
8
|
+
readonly TOO_MANY_CONNECTIONS: "53300";
|
|
9
|
+
readonly CANNOT_CONNECT_NOW: "57P03";
|
|
10
|
+
readonly INVALID_PASSWORD: "28P01";
|
|
11
|
+
readonly INVALID_AUTHORIZATION_SPECIFICATION: "28000";
|
|
12
|
+
readonly INSUFFICIENT_PRIVILEGE: "42501";
|
|
13
|
+
readonly INVALID_CATALOG_NAME: "3D000";
|
|
14
|
+
};
|
|
15
|
+
export type PgErrorCode = (typeof PG_ERROR_CODES)[keyof typeof PG_ERROR_CODES];
|
|
16
|
+
/**
|
|
17
|
+
* Classifies any error raised on the Postgres path into the same verdict
|
|
18
|
+
* shape `classifyClickHouseError` produces — `absent` (migrations table
|
|
19
|
+
* missing), `busy` (connection cap / server not accepting connections),
|
|
20
|
+
* `denied` (auth, missing grant, unknown database), `unreachable` (network,
|
|
21
|
+
* TLS, `pg`'s own timeouts) or `indeterminate`.
|
|
22
|
+
*
|
|
23
|
+
* Pre-masked producer: `message` has been through `maskSensitiveOutput` and
|
|
24
|
+
* bounded AFTER masking — sinks must not re-mask.
|
|
25
|
+
*/
|
|
26
|
+
export declare function classifyPgError(err: unknown): GateErrorClassification;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var o=Object.defineProperty;var _=(E,t)=>o(E,"name",{value:t,configurable:!0});import{getErrorMessage as I,maskSensitiveOutput as A}from"@fjall/util";import{truncateMasked as S}from"./errorMessage.js";const n={UNDEFINED_TABLE:"42P01",TOO_MANY_CONNECTIONS:"53300",CANNOT_CONNECT_NOW:"57P03",INVALID_PASSWORD:"28P01",INVALID_AUTHORIZATION_SPECIFICATION:"28000",INSUFFICIENT_PRIVILEGE:"42501",INVALID_CATALOG_NAME:"3D000"},T=new Set([n.UNDEFINED_TABLE]),C=new Set([n.TOO_MANY_CONNECTIONS,n.CANNOT_CONNECT_NOW]),c=new Set([n.INVALID_PASSWORD,n.INVALID_AUTHORIZATION_SPECIFICATION,n.INSUFFICIENT_PRIVILEGE,n.INVALID_CATALOG_NAME]),r=new Set(["ECONNREFUSED","ECONNRESET","ETIMEDOUT","ENOTFOUND","EAI_AGAIN","EHOSTUNREACH","ECONNABORTED","EPROTO","ABORT_ERR"]),i=/^(?:ERR_(?:TLS|SSL|OSSL)_|CERT_|UNABLE_TO_|DEPTH_ZERO_SELF_SIGNED_CERT$|SELF_SIGNED_CERT_IN_CHAIN$|HOSTNAME_MISMATCH$)/,a=/^timeout expired$|^Query read timeout$|^Connection terminated|socket hang up/i;function D(E){if(typeof E!="object"||E===null||!("code"in E))return;const t=E.code;return typeof t=="string"&&t!==""?t:void 0}_(D,"readCodeProperty");function u(E){return typeof E=="object"&&E!==null&&"name"in E&&E.name==="AbortError"}_(u,"isAbortError");function f(E){const t=I(E),e=D(E),s=S(A(t)),N=s!==""?s:e??"",O=e!==void 0&&{code:e};if(e!==void 0){if(T.has(e))return{class:"absent",code:e,message:N};if(C.has(e))return{class:"busy",code:e,message:N};if(c.has(e))return{class:"denied",code:e,message:N};if(r.has(e)||i.test(e))return{class:"unreachable",code:e,message:N}}return u(E)||a.test(t)?{class:"unreachable",...O,message:N}:{class:"indeterminate",...O,message:N}}_(f,"classifyPgError");export{n as PG_ERROR_CODES,f as classifyPgError};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side bound on one connect / query. The gate identity carries a
|
|
3
|
+
* server-side `max_execution_time = 10` (CONST), so client and server agree
|
|
4
|
+
* on how long a single probe may run.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DB_TIMEOUT_MS = 10000;
|
|
7
|
+
/**
|
|
8
|
+
* Bound on a post-verdict client close. The verdict is already in hand — a
|
|
9
|
+
* close that outlives this is abandoned (the process exits right after,
|
|
10
|
+
* dropping the socket).
|
|
11
|
+
*/
|
|
12
|
+
export declare const CLIENT_CLOSE_TIMEOUT_MS = 2000;
|
|
13
|
+
/** Bound on the forensic `/ping` liveness probe issued after a failed ClickHouse attempt. */
|
|
14
|
+
export declare const LIVENESS_PING_TIMEOUT_MS = 3000;
|
|
15
|
+
/** Worst case one more attempt can cost: probe + close + liveness ping. */
|
|
16
|
+
export declare const ATTEMPT_COST_MS: number;
|
|
17
|
+
/** Slack kept between the retry budget and the ECS `startTimeout` for the final log lines and process exit. */
|
|
18
|
+
export declare const START_TIMEOUT_HEADROOM_MS = 15000;
|
|
19
|
+
/**
|
|
20
|
+
* Retry budget for `busy` and `indeterminate` failures. Derived from the
|
|
21
|
+
* construct's `startTimeout` so the two cannot drift: the run — verdict
|
|
22
|
+
* included — must finish inside the window ECS allows the container.
|
|
23
|
+
*/
|
|
24
|
+
export declare const VERIFY_BUDGET_MS: number;
|
|
25
|
+
/**
|
|
26
|
+
* Retry budget for `unreachable` failures. Class-sized: a server that does
|
|
27
|
+
* not answer at all does not deserve the whole window — the deploy should
|
|
28
|
+
* fail faster and say so.
|
|
29
|
+
*/
|
|
30
|
+
export declare const UNREACHABLE_BUDGET_MS = 45000;
|
|
31
|
+
/** Full-jitter exponential backoff base: attempt n sleeps `random() * min(cap, base * 2^(n-1))`. */
|
|
32
|
+
export declare const BACKOFF_BASE_MS = 1000;
|
|
33
|
+
/** Ceiling on the backoff window before jitter. */
|
|
34
|
+
export declare const BACKOFF_CAP_MS = 10000;
|
|
35
|
+
/** Longest masked error message kept in a log line or outcome detail. */
|
|
36
|
+
export declare const MAX_ERROR_LENGTH = 500;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{SCHEMA_GATE_START_TIMEOUT_SECONDS as _}from"@fjall/util/migration";const T=1e4,o=2e3,E=3e3,M=T+o+E,t=15e3,A=_*1e3-t,O=45e3,r=1e3,p=1e4,c=500;export{M as ATTEMPT_COST_MS,r as BACKOFF_BASE_MS,p as BACKOFF_CAP_MS,o as CLIENT_CLOSE_TIMEOUT_MS,T as DB_TIMEOUT_MS,E as LIVENESS_PING_TIMEOUT_MS,c as MAX_ERROR_LENGTH,t as START_TIMEOUT_HEADROOM_MS,O as UNREACHABLE_BUDGET_MS,A as VERIFY_BUDGET_MS};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound an ALREADY-MASKED message. Mask before truncate: cutting a
|
|
3
|
+
* credential mid-token breaks the mask regex anchors, so the full string is
|
|
4
|
+
* masked first and only the masked output is shortened.
|
|
5
|
+
*/
|
|
6
|
+
export declare function truncateMasked(masked: string): string;
|
|
7
|
+
/** Masked, bounded message for any thrown value — safe for every sink. */
|
|
8
|
+
export declare function maskedError(err: unknown): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var n=Object.defineProperty;var t=(r,o)=>n(r,"name",{value:o,configurable:!0});import{getErrorMessage as i,maskSensitiveOutput as u}from"@fjall/util";import{MAX_ERROR_LENGTH as e}from"./constants.js";function p(r){return r.length>e?`${r.slice(0,e)}...`:r}t(p,"truncateMasked");function m(r){return p(u(i(r)))}t(m,"maskedError");export{m as maskedError,p as truncateMasked};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
1
|
+
export { PG_ERROR_CODES, type PgErrorCode, classifyPgError, } from "./classifyPgError.js";
|
|
2
|
+
export { ATTEMPT_COST_MS, BACKOFF_BASE_MS, BACKOFF_CAP_MS, CLIENT_CLOSE_TIMEOUT_MS, DB_TIMEOUT_MS, LIVENESS_PING_TIMEOUT_MS, MAX_ERROR_LENGTH, START_TIMEOUT_HEADROOM_MS, UNREACHABLE_BUDGET_MS, VERIFY_BUDGET_MS, } from "./constants.js";
|
|
3
|
+
export { type OutcomeBase, type TargetOutcome } from "./outcomes.js";
|
|
4
|
+
export { type ReducibleOutcome, reduceOutcomes, type TargetOutcomeKind, } from "./reduceOutcomes.js";
|
|
5
|
+
export { runSchemaGate } from "./runSchemaGate.js";
|
|
6
|
+
export { SCHEMA_GATE_EXIT, type GateErrorClass, type GateErrorClassification, type SchemaGateChClientOpts, type SchemaGateChHandle, type SchemaGateChPingOpts, type SchemaGateDeps, type SchemaGateExitCode, type SchemaGateExitKind, type SchemaGateLogLevel, type SchemaGateLogger, type SchemaGatePgHandle, } from "./types.js";
|
|
7
|
+
export { type GateTarget, type Liveness, type RetryResult, type WithRetryOpts, withRetry, } from "./withRetry.js";
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{PG_ERROR_CODES as T,classifyPgError as r}from"./classifyPgError.js";import{ATTEMPT_COST_MS as M,BACKOFF_BASE_MS as o,BACKOFF_CAP_MS as O,CLIENT_CLOSE_TIMEOUT_MS as e,DB_TIMEOUT_MS as A,LIVENESS_PING_TIMEOUT_MS as R,MAX_ERROR_LENGTH as t,START_TIMEOUT_HEADROOM_MS as C,UNREACHABLE_BUDGET_MS as I,VERIFY_BUDGET_MS as m}from"./constants.js";import{reduceOutcomes as B}from"./reduceOutcomes.js";import{runSchemaGate as U}from"./runSchemaGate.js";import{SCHEMA_GATE_EXIT as x}from"./types.js";import{withRetry as F}from"./withRetry.js";export{M as ATTEMPT_COST_MS,o as BACKOFF_BASE_MS,O as BACKOFF_CAP_MS,e as CLIENT_CLOSE_TIMEOUT_MS,A as DB_TIMEOUT_MS,R as LIVENESS_PING_TIMEOUT_MS,t as MAX_ERROR_LENGTH,T as PG_ERROR_CODES,x as SCHEMA_GATE_EXIT,C as START_TIMEOUT_HEADROOM_MS,I as UNREACHABLE_BUDGET_MS,m as VERIFY_BUDGET_MS,r as classifyPgError,B as reduceOutcomes,U as runSchemaGate,F as withRetry};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-target verdicts: the shape each verifier produces, the mapping from a
|
|
3
|
+
* retry failure to that shape, and the final forensic log line per kind.
|
|
4
|
+
* Kept apart from the orchestrator so a new outcome kind is added in one
|
|
5
|
+
* place and both the exhaustive log switch and `reduceOutcomes` reject a
|
|
6
|
+
* missed variant at compile time.
|
|
7
|
+
*/
|
|
8
|
+
import type { SchemaGateDeps } from "./types.js";
|
|
9
|
+
import type { GateTarget, Liveness, RetryResult } from "./withRetry.js";
|
|
10
|
+
export interface OutcomeBase {
|
|
11
|
+
target: GateTarget;
|
|
12
|
+
expected: string;
|
|
13
|
+
attempts: number;
|
|
14
|
+
elapsedMs: number;
|
|
15
|
+
}
|
|
16
|
+
export type TargetOutcome = OutcomeBase & ({
|
|
17
|
+
kind: "pass";
|
|
18
|
+
actual: string | null;
|
|
19
|
+
} | {
|
|
20
|
+
kind: "refused";
|
|
21
|
+
actual: string | null;
|
|
22
|
+
detail?: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "denied";
|
|
25
|
+
code?: string;
|
|
26
|
+
detail: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "busy";
|
|
29
|
+
code?: string;
|
|
30
|
+
scope?: "user" | "server";
|
|
31
|
+
detail: string;
|
|
32
|
+
abortedBySibling: boolean;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "unreachable";
|
|
35
|
+
code?: string;
|
|
36
|
+
/** The server answered with an error the gate cannot classify — it was NOT absent. */
|
|
37
|
+
indeterminate: boolean;
|
|
38
|
+
liveness?: Liveness;
|
|
39
|
+
detail: string;
|
|
40
|
+
abortedBySibling: boolean;
|
|
41
|
+
});
|
|
42
|
+
/** Map a retry failure to the outcome the reducer and the final log consume. */
|
|
43
|
+
export declare function outcomeFromFailure(target: GateTarget, expected: string, result: Extract<RetryResult<unknown>, {
|
|
44
|
+
ok: false;
|
|
45
|
+
}>, absentDetail: string): TargetOutcome;
|
|
46
|
+
/**
|
|
47
|
+
* One final line per target. Every non-pass line carries `attempts` and
|
|
48
|
+
* `elapsedMs`; the indeterminate arm says "indeterminate error", never
|
|
49
|
+
* "unreachable" — the forensic log must not claim the server was absent when
|
|
50
|
+
* it answered.
|
|
51
|
+
*/
|
|
52
|
+
export declare function logOutcome(deps: SchemaGateDeps, o: TargetOutcome): void;
|
package/dist/outcomes.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var g=Object.defineProperty;var c=(t,e)=>g(t,"name",{value:e,configurable:!0});import{SCHEMA_GATE_EXIT_HINTS as l}from"@fjall/util/migration";import{truncateMasked as h}from"./errorMessage.js";function y(t,e,r,i){const{classification:n,attempts:u,elapsedMs:f,abortedBySibling:o}=r,a={target:t,expected:e,attempts:u,elapsedMs:f},d=n.code!==void 0&&{code:n.code},s=h(n.message);switch(n.class){case"absent":return{...a,kind:"refused",actual:null,detail:i};case"denied":return{...a,kind:"denied",...d,detail:s};case"busy":return{...a,kind:"busy",...d,...n.scope!==void 0&&{scope:n.scope},detail:s,abortedBySibling:o};case"unreachable":case"indeterminate":return{...a,kind:"unreachable",...d,indeterminate:n.class==="indeterminate",...r.liveness!==void 0&&{liveness:r.liveness},detail:s,abortedBySibling:o};default:{const b=n.class;throw new Error(`unhandled error class ${String(b)}`)}}}c(y,"outcomeFromFailure");function v(t,e){const r={target:e.target,expected:e.expected,attempts:e.attempts,elapsedMs:e.elapsedMs};switch(e.kind){case"pass":t.log("info","Schema version satisfied",{target:e.target,expected:e.expected,actual:e.actual});return;case"refused":t.log("error","REFUSED \u2014 schema version not satisfied",{...r,actual:e.actual,...e.detail!==void 0&&{detail:e.detail}});return;case"denied":t.log("error","DENIED \u2014 gate identity rejected by the server",{...r,...e.code!==void 0&&{code:e.code},error:e.detail,hint:l.denied});return;case"busy":t.log("error","BUSY \u2014 server refused the probe a slot for the whole budget",{...r,...e.code!==void 0&&{code:e.code},...e.scope!==void 0&&{scope:e.scope},error:e.detail,abortedBySibling:e.abortedBySibling,hint:l.busy});return;case"unreachable":t.log("error",e.indeterminate?"Verification target failed with an indeterminate error after retries":"Verification target unreachable after retries",{...r,...e.code!==void 0&&{code:e.code},indeterminate:e.indeterminate,...e.liveness!==void 0&&{liveness:e.liveness},error:e.detail,abortedBySibling:e.abortedBySibling,hint:l.connectionError});return;default:{const i=e;throw new Error(`unhandled outcome ${String(i)}`)}}}c(v,"logOutcome");export{v as logOutcome,y as outcomeFromFailure};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SchemaGateExitKind } from "./types.js";
|
|
2
|
+
export type TargetOutcomeKind = "pass" | "refused" | "busy" | "denied" | "unreachable";
|
|
3
|
+
/**
|
|
4
|
+
* What the reducer sees per target: a verified outcome kind, or
|
|
5
|
+
* `configError` for a target whose verification rejected before it could
|
|
6
|
+
* produce one.
|
|
7
|
+
*/
|
|
8
|
+
export interface ReducibleOutcome {
|
|
9
|
+
kind: TargetOutcomeKind | "configError";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Reduce every target's outcome to one exit kind. Precedence:
|
|
13
|
+
* `configError` > `refused` > `denied` > `busy` > `connectionError` > `pass`
|
|
14
|
+
* — definitive evidence outranks indeterminate, and of the two
|
|
15
|
+
* indeterminate kinds `busy` carries the more specific cure. An empty list
|
|
16
|
+
* reduces to `pass` (the caller guards "nothing to verify" before reaching
|
|
17
|
+
* here).
|
|
18
|
+
*/
|
|
19
|
+
export declare function reduceOutcomes(outcomes: readonly ReducibleOutcome[]): SchemaGateExitKind;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var t=Object.defineProperty;var n=(e,r)=>t(e,"name",{value:r,configurable:!0});const s={configError:0,refused:1,denied:2,busy:3,connectionError:4,pass:5};function u(e){switch(e){case"pass":return"pass";case"refused":return"refused";case"denied":return"denied";case"busy":return"busy";case"unreachable":return"connectionError";case"configError":return"configError";default:{const r=e;throw new Error(`unhandled outcome kind ${String(r)}`)}}}n(u,"exitKindFor");function i(e){let r="pass";for(const c of e){const o=u(c.kind);s[o]<s[r]&&(r=o)}return r}n(i,"reduceOutcomes");export{i as reduceOutcomes};
|
package/dist/runSchemaGate.d.ts
CHANGED
|
@@ -8,22 +8,24 @@
|
|
|
8
8
|
* migrations must boot), and returns an exit code from the taxonomy in
|
|
9
9
|
* `types.ts`.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
11
|
+
* Every failure is classified (`classifyClickHouseError` from
|
|
12
|
+
* `@fjall/clickhouse`, `classifyPgError` here) so the verdict says what the
|
|
13
|
+
* server did — answered and refused a slot (`busy`), answered and rejected
|
|
14
|
+
* the identity (`denied`), or did not answer (`connectionError`) — and the
|
|
15
|
+
* retry policy in `withRetry.ts` waits out transient classes inside a budget
|
|
16
|
+
* derived from the container's ECS `startTimeout`. Targets verify in
|
|
17
|
+
* parallel; a definitive verdict on one aborts the other's backoff.
|
|
18
|
+
*
|
|
19
|
+
* The ClickHouse half authenticates as the read-only `fjall_schema_gate`
|
|
20
|
+
* identity (`FJALL_SCHEMA_GATE_CH_*` env), never as the schema admin. The
|
|
21
|
+
* OCI image (`Dockerfile`) is unchanged by this: it is a build of this
|
|
22
|
+
* package.
|
|
15
23
|
*/
|
|
16
24
|
import { type SchemaGateDeps, type SchemaGateExitCode } from "./types.js";
|
|
17
|
-
export declare const CONNECTION_ATTEMPTS = 3;
|
|
18
|
-
export declare const RETRY_DELAY_MS = 5000;
|
|
19
|
-
/**
|
|
20
|
-
* Bound on how long a post-verdict client close may hold up the exit path.
|
|
21
|
-
* The verdict is already in hand — a close that outlives this is abandoned
|
|
22
|
-
* (the process exits immediately after, dropping the socket).
|
|
23
|
-
*/
|
|
24
|
-
export declare const CLIENT_CLOSE_TIMEOUT_MS = 2000;
|
|
25
25
|
/**
|
|
26
26
|
* Run every configured verification target and reduce to one exit code.
|
|
27
|
-
* See `SCHEMA_GATE_EXIT` for the taxonomy and
|
|
27
|
+
* See `SCHEMA_GATE_EXIT` for the taxonomy and `reduceOutcomes` for the
|
|
28
|
+
* precedence rules. `externalSignal` (process shutdown) composes with the
|
|
29
|
+
* sibling-verdict controller so either stops an in-flight backoff.
|
|
28
30
|
*/
|
|
29
|
-
export declare function runSchemaGate(env: Record<string, string | undefined>, deps: SchemaGateDeps): Promise<SchemaGateExitCode>;
|
|
31
|
+
export declare function runSchemaGate(env: Record<string, string | undefined>, deps: SchemaGateDeps, externalSignal?: AbortSignal): Promise<SchemaGateExitCode>;
|
package/dist/runSchemaGate.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var b=Object.defineProperty;var i=(e,t)=>b(e,"name",{value:t,configurable:!0});import{classifyClickHouseError as N,verifyExpectedClickHouseSchemaVersion as M}from"@fjall/clickhouse";import{EXPECTED_CH_SCHEMA_VERSION_ENV as p,EXPECTED_SCHEMA_VERSION_ENV as g,EXPECTED_SCHEMA_VERSION_TOOL_ENV as A,SCHEMA_GATE_CH_CA_CERT_ENV as P,SCHEMA_GATE_CH_DATABASE_ENV as R,SCHEMA_GATE_CH_PASSWORD_ENV as k,SCHEMA_GATE_CH_URL_ENV as G,SCHEMA_GATE_CH_USER_ENV as V,SCHEMA_GATE_DB_PASSWORD_ENV as v,SCHEMA_GATE_DB_URL_BASE_ENV as C,SCHEMA_GATE_DB_USER_ENV as D,verifyExpectedSchemaVersion as I}from"@fjall/util/migration";import{classifyPgError as O}from"./classifyPgError.js";import{CLIENT_CLOSE_TIMEOUT_MS as U}from"./constants.js";import{maskedError as S}from"./errorMessage.js";import{logOutcome as $,outcomeFromFailure as w}from"./outcomes.js";import{reduceOutcomes as L}from"./reduceOutcomes.js";import{SCHEMA_GATE_EXIT as y}from"./types.js";import{withRetry as T}from"./withRetry.js";const B=new Set(["postgresql:","postgres:"]),x="migrations table absent \u2014 the database has never been migrated while the deployed image expects a schema version",F="migrations audit table absent \u2014 the ClickHouse database has never been migrated while the deployed image expects a schema version",X=new Set(["refused","denied"]);class _ extends Error{static{i(this,"GateConfigError")}}function h(e,t,r){const s=e[t];if(s===void 0||s==="")throw new _(`${r}, but ${t} is missing or empty. The construct injects both together \u2014 an incomplete set points at an author override or a framework bug.`);return s}i(h,"requireEnv");function q(e,t,r){let s;try{s=new URL(e)}catch{throw new _(`${C} is not a parseable URL`)}if(!B.has(s.protocol))throw new _(`${C} has scheme '${s.protocol}' \u2014 the materialised gate supports the postgres engine family only. Set schemaGate: { materialise: false } for this service.`);return s.username=encodeURIComponent(t),s.password=encodeURIComponent(r),s.searchParams.delete("sslmode"),s.toString()}i(q,"composePgUrl");async function H(e,t,r){await Promise.race([r().catch(s=>{e.log("warn",`${t} client close failed`,{error:S(s)})}),e.sleep(U)])}i(H,"closeWithTimeout");async function W(e,t,r,s){const n=`${g} is set`,l=h(e,A,n);if(l!=="prisma")throw new _(`${A}='${l}' \u2014 the materialised gate supports tool "prisma" only (a custom tool's metadata query cannot be expressed through the env contract). Set schemaGate: { materialise: false } for this service.`);const f=h(e,C,n),u=h(e,D,n),E=h(e,v,n),m=q(f,u,E),o=await T(t,{target:"postgres",signal:s,classify:O,attempt:i(async()=>{const a=await t.connectPg(m);try{return await I({tool:"prisma",expected:r,client:a})}finally{await H(t,"Postgres",()=>a.end())}},"attempt")});return o.ok?{target:"postgres",expected:r,attempts:o.attempts,elapsedMs:o.elapsedMs,kind:o.value.matches?"pass":"refused",actual:o.value.actual}:w("postgres",r,o,x)}i(W,"verifyPostgres");async function j(e,t,r,s){const n=`${p} is set`,l=h(e,G,n),f=h(e,V,n),u=h(e,k,n),E=e[P],m=e[R],o=E!==void 0&&E!==""&&{caCert:E},a=await T(t,{target:"clickhouse",signal:s,classify:N,liveness:i(()=>t.pingCh({url:l,...o}),"liveness"),attempt:i(async()=>{const c=t.createChClient({url:l,username:f,password:u,...o});try{return await M({client:c,expected:r,...m!==void 0&&m!==""?{database:m}:{}})}finally{await H(t,"ClickHouse",()=>c.close())}},"attempt")});return a.ok?{target:"clickhouse",expected:r,attempts:a.attempts,elapsedMs:a.elapsedMs,kind:a.value.matches?"pass":"refused",actual:a.value.actual}:w("clickhouse",r,a,F)}i(j,"verifyClickHouse");async function oe(e,t,r){const s=e[g],n=e[p],l=s!==void 0&&s!=="",f=n!==void 0&&n!=="";if(!l&&!f)return t.log("error","Nothing to verify \u2014 no expected-version env present",{hint:`neither ${g} nor ${p} is set; the construct should never materialise a gate container without one`}),y.configError;const u=new AbortController,E=r===void 0?u.signal:AbortSignal.any([r,u.signal]),m=i(c=>c.then(d=>(X.has(d.kind)&&u.abort(),d),d=>{throw u.abort(),d}),"settleFast"),o=[];l&&o.push(m(W(e,t,s,E))),f&&o.push(m(j(e,t,n,E)));const a=[];for(const c of await Promise.allSettled(o)){if(c.status==="fulfilled"){$(t,c.value),a.push(c.value);continue}c.reason instanceof _?t.log("error","Gate configuration invalid",{error:S(c.reason)}):t.log("error","Gate failed unexpectedly",{error:S(c.reason)}),a.push({kind:"configError"})}return y[L(a)]}i(oe,"runSchemaGate");export{oe as runSchemaGate};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Exit taxonomy for the materialised gate container. The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* ECS itself.
|
|
2
|
+
* Exit taxonomy for the materialised gate container. The codes live in
|
|
3
|
+
* `@fjall/util/migration` (`SCHEMA_GATE_EXIT`) because the runner emits
|
|
4
|
+
* them, deploy-core's deployment tail translates them, and the constructs'
|
|
5
|
+
* docs cite them — re-exported here so this package's public surface and
|
|
6
|
+
* its own `bin.ts` read one name.
|
|
8
7
|
*
|
|
9
8
|
* - `pass` (0) — every configured target verified; app containers start.
|
|
10
9
|
* - `refused` (1) — a target's live schema does not satisfy the expected
|
|
11
|
-
* version (forward skew, or a never-migrated database).
|
|
12
|
-
*
|
|
13
|
-
* - `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* version (forward skew, or a never-migrated database).
|
|
11
|
+
* - `configError` (2) — the gate could not even attempt verification.
|
|
12
|
+
* - `connectionError` (3) — the server could not be reached (or answered
|
|
13
|
+
* with an error the gate cannot classify) for the whole budget.
|
|
14
|
+
* - `busy` (4) — the server answered every attempt but refused the probe a
|
|
15
|
+
* slot (ClickHouse 202 / 201, Postgres 53300 / 57P03) for the whole budget.
|
|
16
|
+
* - `denied` (5) — the server rejected the gate identity or its grants.
|
|
17
|
+
* Definitive after one attempt.
|
|
18
18
|
*
|
|
19
|
-
* Precedence
|
|
20
|
-
*
|
|
21
|
-
* (a refusal is definitive evidence, an unreachable sibling is not).
|
|
19
|
+
* Precedence: `configError` > `refused` > `denied` > `busy` >
|
|
20
|
+
* `connectionError` > `pass` — see `reduceOutcomes`.
|
|
22
21
|
*/
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
export { SCHEMA_GATE_EXIT, type SchemaGateExitCode, type SchemaGateExitKind, } from "@fjall/util/migration";
|
|
23
|
+
/**
|
|
24
|
+
* Failure classification shared by both engines. The union is owned by
|
|
25
|
+
* `@fjall/clickhouse` (`classifyClickHouseError`); the Postgres classifier
|
|
26
|
+
* produces the same shape (never `scope`) so the retry policy and outcome
|
|
27
|
+
* mapping consume one type. Coupled discriminated union — re-exported, not
|
|
28
|
+
* redeclared.
|
|
29
|
+
*/
|
|
30
|
+
export type { ClickHouseErrorClass as GateErrorClass, ClickHouseErrorClassification as GateErrorClassification, } from "@fjall/clickhouse";
|
|
30
31
|
export type SchemaGateLogLevel = "info" | "warn" | "error";
|
|
31
32
|
/**
|
|
32
33
|
* Structured one-line JSON log sink. The container's stdout lands in the
|
|
@@ -58,14 +59,31 @@ export interface SchemaGateChClientOpts {
|
|
|
58
59
|
password: string;
|
|
59
60
|
caCert?: string;
|
|
60
61
|
}
|
|
62
|
+
export interface SchemaGateChPingOpts {
|
|
63
|
+
url: string;
|
|
64
|
+
caCert?: string;
|
|
65
|
+
}
|
|
61
66
|
/**
|
|
62
|
-
* Injectable seams. `bin.ts` supplies the real drivers; tests
|
|
63
|
-
* that can express every failure shape (refusal, missing
|
|
64
|
-
*
|
|
67
|
+
* Injectable seams. `bin.ts` supplies the real drivers and clocks; tests
|
|
68
|
+
* supply fakes that can express every failure shape (refusal, missing
|
|
69
|
+
* table, a 202 for the whole budget, a sibling verdict mid-backoff) and make
|
|
70
|
+
* a 105-second budget run in microseconds — a fake that cannot express the
|
|
71
|
+
* bug shape cannot catch it.
|
|
65
72
|
*/
|
|
66
73
|
export interface SchemaGateDeps {
|
|
67
74
|
connectPg(connectionUrl: string): Promise<SchemaGatePgHandle>;
|
|
68
75
|
createChClient(opts: SchemaGateChClientOpts): SchemaGateChHandle;
|
|
69
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Bounded liveness probe of the ClickHouse `/ping` endpoint (which does not
|
|
78
|
+
* authenticate). Resolves `false` on any failure and never throws — the
|
|
79
|
+
* result is forensic only and never changes a verdict.
|
|
80
|
+
*/
|
|
81
|
+
pingCh(opts: SchemaGateChPingOpts): Promise<boolean>;
|
|
82
|
+
/** Abort-aware sleep: resolves early (never rejects) when `signal` fires. */
|
|
83
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
84
|
+
/** Uniform sample in [0, 1) for backoff jitter. */
|
|
85
|
+
random(): number;
|
|
86
|
+
/** Millisecond clock the retry budget is measured against. */
|
|
87
|
+
now(): number;
|
|
70
88
|
log: SchemaGateLogger;
|
|
71
89
|
}
|
package/dist/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import{SCHEMA_GATE_EXIT as r}from"@fjall/util/migration";export{r as SCHEMA_GATE_EXIT};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy for one verification target. The loop runs until a verdict:
|
|
3
|
+
*
|
|
4
|
+
* - success → done.
|
|
5
|
+
* - `absent` → stop (a definitive refusal — the table has never existed).
|
|
6
|
+
* - `denied` → stop after this one attempt (no retry will change it).
|
|
7
|
+
* - `busy` / `unreachable` / `indeterminate` → full-jitter exponential
|
|
8
|
+
* backoff, bounded by a budget sized to the class of the MOST RECENT
|
|
9
|
+
* failure: `unreachable` gets `UNREACHABLE_BUDGET_MS`, the rest
|
|
10
|
+
* `VERIFY_BUDGET_MS`. The stop rule is checked before sleeping —
|
|
11
|
+
* `elapsed + delay + ATTEMPT_COST_MS > budget` ends the run with the last
|
|
12
|
+
* classification — so the gate always exits with a verdict before ECS's
|
|
13
|
+
* `startTimeout` cuts it off.
|
|
14
|
+
*
|
|
15
|
+
* Attempt count is a consequence of the budget, not a constant: the
|
|
16
|
+
* 2026-08-25 incident (3 × 5s against a minutes-long OPTIMIZE holder) is
|
|
17
|
+
* exactly the shape a fixed count cannot wait out.
|
|
18
|
+
*/
|
|
19
|
+
import type { GateErrorClassification, SchemaGateDeps } from "./types.js";
|
|
20
|
+
export type Liveness = "alive" | "dead";
|
|
21
|
+
export type GateTarget = "postgres" | "clickhouse";
|
|
22
|
+
export interface WithRetryOpts<T> {
|
|
23
|
+
target: GateTarget;
|
|
24
|
+
attempt: () => Promise<T>;
|
|
25
|
+
classify: (err: unknown) => GateErrorClassification;
|
|
26
|
+
/** Fires when a sibling target reaches a definitive verdict or the process is shutting down. */
|
|
27
|
+
signal: AbortSignal;
|
|
28
|
+
/**
|
|
29
|
+
* Forensic liveness probe run after an `unreachable` / `indeterminate`
|
|
30
|
+
* failure. Never changes the class — "the server answered `/ping` while
|
|
31
|
+
* every query failed" is a different investigation from "nothing answered".
|
|
32
|
+
*/
|
|
33
|
+
liveness?: () => Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
export type RetryResult<T> = {
|
|
36
|
+
ok: true;
|
|
37
|
+
value: T;
|
|
38
|
+
attempts: number;
|
|
39
|
+
elapsedMs: number;
|
|
40
|
+
} | {
|
|
41
|
+
ok: false;
|
|
42
|
+
classification: GateErrorClassification;
|
|
43
|
+
attempts: number;
|
|
44
|
+
elapsedMs: number;
|
|
45
|
+
abortedBySibling: boolean;
|
|
46
|
+
liveness?: Liveness;
|
|
47
|
+
};
|
|
48
|
+
export declare function withRetry<T>(deps: SchemaGateDeps, opts: WithRetryOpts<T>): Promise<RetryResult<T>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var w=Object.defineProperty;var a=(e,t)=>w(e,"name",{value:t,configurable:!0});import{ATTEMPT_COST_MS as M,BACKOFF_BASE_MS as m,BACKOFF_CAP_MS as y,LIVENESS_PING_TIMEOUT_MS as _,UNREACHABLE_BUDGET_MS as v,VERIFY_BUDGET_MS as S}from"./constants.js";function h(e){return e.class==="busy"||e.class==="unreachable"||e.class==="indeterminate"}a(h,"isRetryable");function E(e){return e.class==="unreachable"||e.class==="indeterminate"}a(E,"wantsLiveness");function A(e){return e.class==="unreachable"?v:S}a(A,"budgetFor");function B(e,t){const r=Math.min(y,m*2**(t-1));return Math.floor(e.random()*r)}a(B,"backoffDelay");async function T(e,t,r){return await Promise.race([t(),e.sleep(_,r).then(()=>!1)])?"alive":"dead"}a(T,"probeLiveness");async function C(e,t){const r=e.now();let l=0,s;for(;;){l+=1;let n;try{return{ok:!0,value:await t.attempt(),attempts:l,elapsedMs:e.now()-r}}catch(o){n=t.classify(o)}const c=h(n);c&&t.liveness!==void 0&&E(n)&&(s=await T(e,t.liveness,t.signal));const u=e.now()-r,d=A(n),i=c?B(e,l):null,f=i!==null&&u+i+M<=d,b=f&&!t.signal.aborted;e.log("warn","Verification attempt failed",{target:t.target,attempt:l,class:n.class,...n.code!==void 0&&{code:n.code},...n.scope!==void 0&&{scope:n.scope},error:n.message,elapsedMs:u,nextDelayMs:b?i:null,budgetMs:d,...s!==void 0&&{liveness:s}});const g=a(o=>({ok:!1,classification:n,attempts:l,elapsedMs:e.now()-r,abortedBySibling:o,...s!==void 0&&{liveness:s}}),"failed");if(!f)return g(t.signal.aborted);if(b&&await e.sleep(i,t.signal),t.signal.aborted)return e.log("warn","Retry abandoned \u2014 abort signalled before the next attempt",{target:t.target,attempt:l,abortedBySibling:!0}),g(!0)}}a(C,"withRetry");export{C as withRetry};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/schema-gate",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@clickhouse/client": "^1.23.1",
|
|
54
|
-
"@fjall/clickhouse": "^
|
|
55
|
-
"@fjall/util": "^
|
|
54
|
+
"@fjall/clickhouse": "^21.0.0",
|
|
55
|
+
"@fjall/util": "^21.0.0",
|
|
56
56
|
"pg": "^8.16.3"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|