@fjall/schema-gate 20.0.0 → 21.1.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.
@@ -9,16 +9,22 @@
9
9
  "package.json",
10
10
  "tsconfig.json"
11
11
  ],
12
- "hash": "c9486b00a2df3c22134ea678a22dc1c7724c567938464633624ceb0713f33f29",
12
+ "hash": "a8bfe6deb4a7b2982b70aaae34fb5669ffdb83ba87f98bd55abe0b6da0f1a188",
13
13
  "files": {
14
- "src/bin.ts": "3acde81b3b914cc4",
15
- "src/index.ts": "ab1151c1b7f1bc38",
16
- "src/runSchemaGate.ts": "f0a4b0fec6a095de",
17
- "src/types.ts": "67a71f4a53cc1e83",
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": "76d147d6257c2efa",
25
+ "package.json": "a8c1f80c1eaf67fd",
20
26
  "tsconfig.json": "8a11f0e3fce6a36a"
21
27
  },
22
- "outputHash": "7d6a19dccfe4e0492be3059a42b7d1a6f92f5d13a09b9678b3f3d27d59addca0",
23
- "outputFileCount": 9
28
+ "outputHash": "12d3f76751e40922d0608a51baddb56e78f6ca0033360d1aa2d73dc76925e8ef",
29
+ "outputFileCount": 21
24
30
  }
package/dist/.minified CHANGED
@@ -1 +1 @@
1
- 4 files minified at 2026-08-24T18:04:58.941Z
1
+ 10 files minified at 2026-08-28T12:40:18.192Z
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 m=Object.defineProperty;var o=(e,t)=>m(e,"name",{value:t,configurable:!0});import{createFjallClickHouseClient as p}from"@fjall/clickhouse";import{getErrorMessage as s,maskSensitiveOutput as a}from"@fjall/util";import d from"pg";import{runSchemaGate as f}from"./runSchemaGate.js";import{SCHEMA_GATE_EXIT as S}from"./types.js";const{Client:g}=d,w="FjallSchemaGate",i=1e4;function r(e,t,n={}){process.stdout.write(`${JSON.stringify({timestamp:new Date().toISOString(),level:e,namespace:w,message:t,...n})}
3
- `)}o(r,"log");function u(e,t){return new g({connectionString:e,connectionTimeoutMillis:i,query_timeout:i,statement_timeout:i,...t?{ssl:{rejectUnauthorized:!1}}:{}})}o(u,"buildPgClient");const C={async connectPg(e){const t=u(e,!0);try{return await t.connect(),t}catch(n){if(!/does not support SSL/i.test(s(n)))throw n;await t.end().catch(l=>{r("warn","Postgres TLS probe cleanup failed",{error:a(s(l))})}),r("info","Server does not support TLS \u2014 connecting in plaintext");const c=u(e,!1);return await c.connect(),c}},createChClient(e){return p({url:e.url,username:e.username,password:e.password,...e.caCert!==void 0?{caCert:e.caCert}:{},request_timeout:i})},sleep:o(e=>new Promise(t=>setTimeout(t,e)),"sleep"),log:r};for(const[e,t]of[["SIGTERM",143],["SIGINT",130]])process.on(e,()=>{r("warn",`Received ${e} \u2014 exiting without a verdict`),process.exit(t)});f(process.env,C).then(e=>{process.exit(e)}).catch(e=>{r("error","Gate crashed",{error:a(s(e))}),process.exit(S.configError)});
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 { CONNECTION_ATTEMPTS, RETRY_DELAY_MS, runSchemaGate, } from "./runSchemaGate.js";
2
- export { SCHEMA_GATE_EXIT, type SchemaGateChClientOpts, type SchemaGateChHandle, type SchemaGateDeps, type SchemaGateExitCode, type SchemaGateLogLevel, type SchemaGateLogger, type SchemaGatePgHandle, } from "./types.js";
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{CONNECTION_ATTEMPTS as r,RETRY_DELAY_MS as _,runSchemaGate as e}from"./runSchemaGate.js";import{SCHEMA_GATE_EXIT as A}from"./types.js";export{r as CONNECTION_ATTEMPTS,_ as RETRY_DELAY_MS,A as SCHEMA_GATE_EXIT,e as runSchemaGate};
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;
@@ -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};
@@ -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
- * Connection attempts are bounded: `CONNECTION_ATTEMPTS` tries,
12
- * `RETRY_DELAY_MS` apart, per target. Targets verify in parallel so the
13
- * worst-case wall clock stays inside the dependent containers'
14
- * `startTimeout` (120s) with headroom.
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 its precedence rules.
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>;
@@ -1 +1 @@
1
- var R=Object.defineProperty;var i=(e,t)=>R(e,"name",{value:t,configurable:!0});import{verifyExpectedClickHouseSchemaVersion as k}from"@fjall/clickhouse";import{maskSensitiveOutput as H}from"@fjall/util";import{EXPECTED_CH_SCHEMA_VERSION_ENV as m,EXPECTED_SCHEMA_VERSION_ENV as p,EXPECTED_SCHEMA_VERSION_TOOL_ENV as C,SCHEMA_ADMIN_PASSWORD_ENV as M,SCHEMA_ADMIN_USER_ENV as b,SCHEMA_GATE_CH_CA_CERT_ENV as P,SCHEMA_GATE_CH_DATABASE_ENV as V,SCHEMA_GATE_CH_URL_ENV as D,SCHEMA_GATE_DB_PASSWORD_ENV as G,SCHEMA_GATE_DB_URL_BASE_ENV as S,SCHEMA_GATE_DB_USER_ENV as O,verifyExpectedSchemaVersion as U}from"@fjall/util/migration";import{SCHEMA_GATE_EXIT as f}from"./types.js";const g=3,x=5e3,w=500,v="42P01",I=new Set(["60","81"]),$=2e3,L=new Set(["postgresql:","postgres:"]);class _ extends Error{static{i(this,"GateConfigError")}}function d(e){const t=e instanceof Error?e.message:String(e),a=H(t);return a.length>w?`${a.slice(0,w)}...`:a}i(d,"maskedError");function E(e,t,a){const r=e[t];if(r===void 0||r==="")throw new _(`${a}, 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 r}i(E,"requireEnv");function B(e,t,a){let r;try{r=new URL(e)}catch{throw new _(`${S} is not a parseable URL`)}if(!L.has(r.protocol))throw new _(`${S} has scheme '${r.protocol}' \u2014 the materialised gate supports the postgres engine family only. Set schemaGate: { materialise: false } for this service.`);return r.username=encodeURIComponent(t),r.password=encodeURIComponent(a),r.searchParams.delete("sslmode"),r.toString()}i(B,"composePgUrl");function y(e){return typeof e=="object"&&e!==null&&"code"in e&&e.code===v}i(y,"isUndefinedTableError");function A(e){if(typeof e!="object"||e===null)return!1;const t=e.code;return typeof t=="string"&&I.has(t)}i(A,"isChNeverMigratedError");async function T(e,t,a){await Promise.race([a().catch(r=>{e.log("warn",`${t} client close failed`,{error:d(r)})}),e.sleep($)])}i(T,"closeWithTimeout");async function N(e,t,a){let r;for(let n=1;n<=g;n++)try{return await a()}catch(s){if(r=s,y(s)||A(s))throw s;e.log("warn","Verification attempt failed",{target:t,attempt:n,of:g,error:d(s)}),n<g&&await e.sleep(x)}throw r}i(N,"withRetry");async function X(e,t,a){const r=`${p} is set`,n=E(e,C,r);if(n!=="prisma")throw new _(`${C}='${n}' \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 s=E(e,S,r),u=E(e,O,r),l=E(e,G,r),o=B(s,u,l);try{const c=await N(t,"postgres",async()=>{const h=await t.connectPg(o);try{return await U({tool:"prisma",expected:a,client:h})}finally{await T(t,"Postgres",()=>h.end())}});return{target:"postgres",kind:c.matches?"pass":"refused",expected:a,actual:c.actual}}catch(c){return y(c)?{target:"postgres",kind:"refused",expected:a,actual:null,detail:"migrations table absent \u2014 the database has never been migrated while the deployed image expects a schema version"}:{target:"postgres",kind:"connection-error",expected:a,detail:d(c)}}}i(X,"verifyPostgres");async function j(e,t,a){const r=`${m} is set`,n=E(e,D,r),s=E(e,b,r),u=E(e,M,r),l=e[P],o=e[V];try{const c=await N(t,"clickhouse",async()=>{const h=t.createChClient({url:n,username:s,password:u,...l!==void 0&&l!==""?{caCert:l}:{}});try{return await k({client:h,expected:a,...o!==void 0&&o!==""?{database:o}:{}})}finally{await T(t,"ClickHouse",()=>h.close())}});return{target:"clickhouse",kind:c.matches?"pass":"refused",expected:a,actual:c.actual}}catch(c){return A(c)?{target:"clickhouse",kind:"refused",expected:a,actual:null,detail:"migrations audit table absent \u2014 the ClickHouse database has never been migrated while the deployed image expects a schema version"}:{target:"clickhouse",kind:"connection-error",expected:a,detail:d(c)}}}i(j,"verifyClickHouse");async function J(e,t){const a=e[p],r=e[m],n=a!==void 0&&a!=="",s=r!==void 0&&r!=="";if(!n&&!s)return t.log("error","Nothing to verify \u2014 no expected-version env present",{hint:`neither ${p} nor ${m} is set; the construct should never materialise a gate container without one`}),f.configError;const u=[];n&&u.push(X(e,t,a)),s&&u.push(j(e,t,r));let l;try{l=await Promise.all(u)}catch(o){return o instanceof _?(t.log("error","Gate configuration invalid",{error:d(o)}),f.configError):(t.log("error","Gate failed unexpectedly",{error:d(o)}),f.configError)}for(const o of l)o.kind==="pass"?t.log("info","Schema version satisfied",{target:o.target,expected:o.expected,actual:o.actual??null}):o.kind==="refused"?t.log("error","REFUSED \u2014 schema version not satisfied",{target:o.target,expected:o.expected,actual:o.actual??null,...o.detail!==void 0?{detail:o.detail}:{}}):t.log("error","Verification target unreachable after retries",{target:o.target,expected:o.expected,attempts:g,...o.detail!==void 0?{error:o.detail}:{}});return l.some(o=>o.kind==="refused")?f.refused:l.some(o=>o.kind==="connection-error")?f.connectionError:f.pass}i(J,"runSchemaGate");export{$ as CLIENT_CLOSE_TIMEOUT_MS,g as CONNECTION_ATTEMPTS,x as RETRY_DELAY_MS,J as runSchemaGate};
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 dependent app
3
- * containers declare `dependsOn: [{ container: "fjall-schema-gate",
4
- * condition: "SUCCESS" }]`, so ANY non-zero exit stops the task pre-RUNNING
5
- * and counts toward the deployment circuit breaker's stage-1 arithmetic
6
- * the codes exist for the forensic log and the deploy-time ECS tail, not for
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). The gate did its
12
- * job; the deploy must roll back or the migration must land first.
13
- * - `configError` (2) — the gate could not even attempt verification:
14
- * missing/empty env, unsupported tool or engine family, or an internal
15
- * error. Points at a construct bug or an author override.
16
- * - `connectionError` (3) a target was unreachable after the bounded
17
- * retry budget. Indeterminate: the schema may be fine.
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 when targets disagree: `configError` is detected before any
20
- * connection is attempted; a verified `refused` outranks `connectionError`
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 declare const SCHEMA_GATE_EXIT: {
24
- readonly pass: 0;
25
- readonly refused: 1;
26
- readonly configError: 2;
27
- readonly connectionError: 3;
28
- };
29
- export type SchemaGateExitCode = (typeof SCHEMA_GATE_EXIT)[keyof typeof SCHEMA_GATE_EXIT];
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 supply fakes
63
- * that can express every failure shape (refusal, missing table, connect
64
- * timeout) a fake that cannot express the bug shape cannot catch it.
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
- sleep(ms: number): Promise<void>;
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
- const r={pass:0,refused:1,configError:2,connectionError:3};export{r as SCHEMA_GATE_EXIT};
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": "20.0.0",
3
+ "version": "21.1.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": "^20.0.0",
55
- "@fjall/util": "^20.0.0",
54
+ "@fjall/clickhouse": "^21.1.0",
55
+ "@fjall/util": "^21.1.0",
56
56
  "pg": "^8.16.3"
57
57
  },
58
58
  "engines": {