@koralabs/kora-labs-common 6.13.4 → 6.14.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/chain/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './transport/fetchProviderJson';
2
+ export * from './transport/rateLimit';
2
3
  export * from './failover/interfaces';
3
4
  export * from './failover/ChainProviderFailover';
4
5
  export * from './cache/ShortTermCache';
package/chain/index.js CHANGED
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  // NOT re-exported from the root index — this is a server-side chain-access layer and pulls Node-only
19
19
  // deps. Keeping it off the root barrel preserves the isomorphic root (same rule the `aws` module follows).
20
20
  __exportStar(require("./transport/fetchProviderJson"), exports);
21
+ __exportStar(require("./transport/rateLimit"), exports);
21
22
  __exportStar(require("./failover/interfaces"), exports);
22
23
  __exportStar(require("./failover/ChainProviderFailover"), exports);
23
24
  __exportStar(require("./cache/ShortTermCache"), exports);
@@ -1,26 +1,30 @@
1
+ import { HeaderReader } from './rateLimit';
1
2
  export type ProviderName = 'Koios' | 'Blockfrost';
2
3
  type ProviderFetcher = (url: string, init?: {
3
4
  method?: string;
4
5
  headers?: Record<string, string>;
5
- body?: string;
6
+ body?: string | Uint8Array;
6
7
  }) => Promise<{
7
8
  ok: boolean;
8
9
  status: number;
9
10
  statusText: string;
10
11
  text: () => Promise<string>;
12
+ headers?: HeaderReader;
11
13
  }>;
12
14
  export interface ProviderRequestOptions {
13
15
  provider: ProviderName;
14
16
  url: string;
15
17
  method?: string;
16
18
  headers?: Record<string, string>;
17
- body?: string;
19
+ body?: string | Uint8Array;
18
20
  maxRetries?: number;
19
21
  retryBaseDelayMs?: number;
20
22
  maxRps?: number;
21
23
  rateLimitKey?: string;
24
+ /** Longest server-stated rate-limit wait to sit out in-process; longer waits throw RateLimitedError. */
25
+ maxRateLimitWaitMs?: number;
22
26
  fetcher?: ProviderFetcher;
23
27
  }
24
28
  export declare const isRetriableError: (provider: ProviderName, error: any) => boolean;
25
- export declare const fetchProviderJson: <T>({ provider, url, method, headers, body, maxRetries, retryBaseDelayMs, maxRps, rateLimitKey, fetcher }: ProviderRequestOptions) => Promise<T>;
29
+ export declare const fetchProviderJson: <T>({ provider, url, method, headers, body, maxRetries, retryBaseDelayMs, maxRps, rateLimitKey, maxRateLimitWaitMs, fetcher }: ProviderRequestOptions) => Promise<T>;
26
30
  export {};
@@ -2,20 +2,23 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fetchProviderJson = exports.isRetriableError = void 0;
4
4
  const utils_1 = require("../../utils");
5
+ const rateLimit_1 = require("./rateLimit");
5
6
  const DEFAULT_MAX_RPS = 5;
6
7
  const DEFAULT_MAX_RETRIES = 2;
7
8
  const DEFAULT_RETRY_BASE_DELAY_MS = 500;
8
- const RETRIABLE_STATUS_CODES = new Set([403, 408, 425, 429, 500, 502, 503, 504]);
9
+ const DEFAULT_MAX_RATE_LIMIT_WAIT_MS = 30000;
10
+ const MAX_RATE_LIMIT_WAITS = 3;
11
+ // 429 is deliberately absent: a rate limit is retried only after the wait the server states (see
12
+ // fetchProviderJson), never on a blind backoff.
13
+ const RETRIABLE_STATUS_CODES = new Set([403, 408, 425, 500, 502, 503, 504]);
9
14
  const RETRIABLE_MESSAGE_SNIPPETS = [
10
15
  'terminated',
11
16
  'socket',
12
17
  'econnreset',
13
18
  'fetch failed',
14
19
  'gateway timeout',
15
- 'too many requests',
16
20
  'payload too large',
17
21
  'timed out acquiring connection from connection pool',
18
- 'rate limit',
19
22
  'service unavailable',
20
23
  'temporarily unavailable'
21
24
  ];
@@ -126,16 +129,25 @@ const isRetriableError = (provider, error) => {
126
129
  };
127
130
  exports.isRetriableError = isRetriableError;
128
131
  const defaultFetch = (url, init) => globalThis.fetch(url, init);
129
- const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body, maxRetries = DEFAULT_MAX_RETRIES, retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, maxRps = DEFAULT_MAX_RPS, rateLimitKey = provider, fetcher = defaultFetch }) => {
132
+ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body, maxRetries = DEFAULT_MAX_RETRIES, retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, maxRps = DEFAULT_MAX_RPS, rateLimitKey = provider, maxRateLimitWaitMs = DEFAULT_MAX_RATE_LIMIT_WAIT_MS, fetcher = defaultFetch }) => {
130
133
  var _a, _b, _c, _d;
131
134
  let attempt = 0;
135
+ let rateLimitWaits = 0;
132
136
  // Retry loop: exits only via return (success) or throw (non-retriable / retries exhausted).
133
137
  // eslint-disable-next-line no-constant-condition
134
138
  while (true) {
139
+ // A wait recorded by ANY earlier call on this key (this process) is honored before calling again.
140
+ await (0, rateLimit_1.waitForRateLimit)(rateLimitKey, maxRateLimitWaitMs);
141
+ let rateLimited;
135
142
  try {
136
143
  const response = await runRateLimitedTask(rateLimitKey, maxRps, () => fetcher(url, { method, headers, body }));
137
144
  const responseText = await response.text();
138
145
  const { value: parsed, parseError } = parseJson(responseText);
146
+ if (response.ok && isQuotaExhausted(response.headers)) {
147
+ const waitMs = (0, rateLimit_1.statedWaitMs)(response.headers, undefined);
148
+ if (waitMs !== null)
149
+ (0, rateLimit_1.recordRateLimit)(rateLimitKey, waitMs);
150
+ }
139
151
  if (parseError && responseText) {
140
152
  throw createProviderError({
141
153
  provider,
@@ -149,9 +161,18 @@ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body,
149
161
  if (!response.ok || payloadError) {
150
162
  const status = Number((_b = (_a = payloadError === null || payloadError === void 0 ? void 0 : payloadError.status_code) !== null && _a !== void 0 ? _a : payloadError === null || payloadError === void 0 ? void 0 : payloadError.status) !== null && _b !== void 0 ? _b : response.status);
151
163
  const statusText = response.statusText || `${(_d = (_c = payloadError === null || payloadError === void 0 ? void 0 : payloadError.error) !== null && _c !== void 0 ? _c : payloadError === null || payloadError === void 0 ? void 0 : payloadError.message) !== null && _d !== void 0 ? _d : 'Request failed'}`;
152
- throw createProviderError({ provider, status, statusText, payload: payloadError !== null && payloadError !== void 0 ? payloadError : parsed, responseText });
164
+ const error = createProviderError({ provider, status, statusText, payload: payloadError !== null && payloadError !== void 0 ? payloadError : parsed, responseText });
165
+ const waitMs = (0, rateLimit_1.statedWaitMs)(response.headers, parsed);
166
+ if (status === 429 || waitMs !== null) {
167
+ rateLimited = { waitMs, error };
168
+ }
169
+ else {
170
+ throw error;
171
+ }
172
+ }
173
+ else {
174
+ return parsed;
153
175
  }
154
- return parsed;
155
176
  }
156
177
  catch (error) {
157
178
  if (attempt >= maxRetries || !(0, exports.isRetriableError)(provider, error)) {
@@ -159,7 +180,18 @@ const fetchProviderJson = async ({ provider, url, method = 'GET', headers, body,
159
180
  }
160
181
  attempt++;
161
182
  await delay(retryBaseDelayMs * attempt);
183
+ continue;
184
+ }
185
+ // Rate limited: never call again before the stated time; with no stated time, stop.
186
+ const { waitMs, error } = rateLimited;
187
+ if (waitMs === null)
188
+ throw error;
189
+ (0, rateLimit_1.recordRateLimit)(rateLimitKey, waitMs);
190
+ // Sitting out a stated wait is not a blind retry, so it does not spend `maxRetries` — but it is bounded.
191
+ if (++rateLimitWaits > MAX_RATE_LIMIT_WAITS || waitMs > maxRateLimitWaitMs) {
192
+ throw new rateLimit_1.RateLimitedError(rateLimitKey, waitMs, error.message);
162
193
  }
163
194
  }
164
195
  };
165
196
  exports.fetchProviderJson = fetchProviderJson;
197
+ const isQuotaExhausted = (headers) => { var _a, _b; return ((_a = headers === null || headers === void 0 ? void 0 : headers.get('x-ratelimit-remaining')) === null || _a === void 0 ? void 0 : _a.trim()) === '0' || ((_b = headers === null || headers === void 0 ? void 0 : headers.get('ratelimit-remaining')) === null || _b === void 0 ? void 0 : _b.trim()) === '0'; };
@@ -0,0 +1,21 @@
1
+ export interface HeaderReader {
2
+ get(name: string): string | null | undefined;
3
+ }
4
+ export declare class RateLimitedError extends Error {
5
+ readonly key: string;
6
+ readonly retryAfterMs: number;
7
+ constructor(key: string, retryAfterMs: number, detail?: string);
8
+ }
9
+ /**
10
+ * The wait (ms) a response asks for, or null when it states none. Covers Retry-After (seconds or
11
+ * date), X-RateLimit-Reset-After, RateLimit-Reset, X-RateLimit-Remaining: 0 + X-RateLimit-Reset,
12
+ * and waits written in the body (Discord `retry_after`, Google `RetryInfo.retryDelay`).
13
+ */
14
+ export declare const statedWaitMs: (headers: HeaderReader | undefined, body: unknown, now?: number) => number | null;
15
+ /** Record that `key` must not be called for `waitMs` (only ever extends an existing block). */
16
+ export declare const recordRateLimit: (key: string, waitMs: number, now?: number) => void;
17
+ export declare const rateLimitWaitRemainingMs: (key: string, now?: number) => number;
18
+ /** Wait out a recorded limit before calling `key`; throw (returning the wait) when it exceeds `maxWaitMs`. */
19
+ export declare const waitForRateLimit: (key: string, maxWaitMs: number) => Promise<void>;
20
+ /** Test seam: forget every recorded limit. */
21
+ export declare const resetRateLimits: () => void;
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ // Rate-limit signals are law: every provider call honors the wait a server states, remembers it for
3
+ // the whole process (so other callers don't hit the same limit), and never retries early. When the
4
+ // stated wait is too long to hold the caller, it stops and hands the wait back (RateLimitedError).
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resetRateLimits = exports.waitForRateLimit = exports.rateLimitWaitRemainingMs = exports.recordRateLimit = exports.statedWaitMs = exports.RateLimitedError = void 0;
7
+ class RateLimitedError extends Error {
8
+ constructor(key, retryAfterMs, detail = '') {
9
+ super(`Rate limited by ${key}: retry after ${Math.ceil(retryAfterMs / 1000)}s${detail ? ` (${detail})` : ''}`);
10
+ this.key = key;
11
+ this.retryAfterMs = retryAfterMs;
12
+ this.name = 'RateLimitedError';
13
+ }
14
+ }
15
+ exports.RateLimitedError = RateLimitedError;
16
+ const blockedUntilByKey = new Map();
17
+ const toMs = (seconds) => Math.max(0, Math.ceil(seconds * 1000));
18
+ const parseSeconds = (raw) => {
19
+ if (raw === null || raw === undefined || raw.trim() === '')
20
+ return null;
21
+ const value = Number(raw.trim().replace(/s$/i, ''));
22
+ return Number.isFinite(value) ? value : null;
23
+ };
24
+ /** `Retry-After` is either delta-seconds or an HTTP date. */
25
+ const parseRetryAfter = (raw, now) => {
26
+ const seconds = parseSeconds(raw);
27
+ if (seconds !== null)
28
+ return toMs(seconds);
29
+ if (!raw)
30
+ return null;
31
+ const date = Date.parse(raw);
32
+ return Number.isFinite(date) ? Math.max(0, date - now) : null;
33
+ };
34
+ /** `X-RateLimit-Reset` is an epoch (seconds or ms) on most APIs, a delta on a few; disambiguate by size. */
35
+ const parseReset = (raw, now) => {
36
+ const value = parseSeconds(raw);
37
+ if (value === null)
38
+ return null;
39
+ if (value > 1e12)
40
+ return Math.max(0, value - now); // epoch ms
41
+ if (value > 1e9)
42
+ return Math.max(0, value * 1000 - now); // epoch seconds
43
+ return toMs(value); // delta seconds
44
+ };
45
+ const bodyWaitMs = (body) => {
46
+ var _a, _b;
47
+ if (!body || typeof body !== 'object')
48
+ return null;
49
+ const record = body;
50
+ // Discord: { retry_after: seconds }
51
+ const retryAfter = parseSeconds(record.retry_after === undefined ? undefined : String(record.retry_after));
52
+ if (retryAfter !== null)
53
+ return toMs(retryAfter);
54
+ // Google: { error: { details: [{ '@type': '...RetryInfo', retryDelay: '1.5s' }] } }
55
+ const details = (_b = (_a = record.error) === null || _a === void 0 ? void 0 : _a.details) !== null && _b !== void 0 ? _b : [];
56
+ const retryInfo = details.find((d) => typeof (d === null || d === void 0 ? void 0 : d['@type']) === 'string' && d['@type'].endsWith('RetryInfo'));
57
+ const delay = parseSeconds(retryInfo === null || retryInfo === void 0 ? void 0 : retryInfo.retryDelay);
58
+ return delay === null ? null : toMs(delay);
59
+ };
60
+ /**
61
+ * The wait (ms) a response asks for, or null when it states none. Covers Retry-After (seconds or
62
+ * date), X-RateLimit-Reset-After, RateLimit-Reset, X-RateLimit-Remaining: 0 + X-RateLimit-Reset,
63
+ * and waits written in the body (Discord `retry_after`, Google `RetryInfo.retryDelay`).
64
+ */
65
+ const statedWaitMs = (headers, body, now = Date.now()) => {
66
+ var _a;
67
+ const header = (name) => { var _a; return (_a = headers === null || headers === void 0 ? void 0 : headers.get(name)) !== null && _a !== void 0 ? _a : null; };
68
+ const candidates = [
69
+ parseRetryAfter(header('retry-after'), now),
70
+ parseSeconds(header('x-ratelimit-reset-after')) === null ? null : toMs(parseSeconds(header('x-ratelimit-reset-after'))),
71
+ parseSeconds(header('ratelimit-reset')) === null ? null : toMs(parseSeconds(header('ratelimit-reset'))),
72
+ ((_a = header('x-ratelimit-remaining')) === null || _a === void 0 ? void 0 : _a.trim()) === '0' ? parseReset(header('x-ratelimit-reset'), now) : null,
73
+ bodyWaitMs(body)
74
+ ].filter((ms) => ms !== null);
75
+ return candidates.length > 0 ? Math.max(...candidates) : null;
76
+ };
77
+ exports.statedWaitMs = statedWaitMs;
78
+ /** Record that `key` must not be called for `waitMs` (only ever extends an existing block). */
79
+ const recordRateLimit = (key, waitMs, now = Date.now()) => {
80
+ var _a;
81
+ const until = now + waitMs;
82
+ if (until > ((_a = blockedUntilByKey.get(key)) !== null && _a !== void 0 ? _a : 0))
83
+ blockedUntilByKey.set(key, until);
84
+ };
85
+ exports.recordRateLimit = recordRateLimit;
86
+ const rateLimitWaitRemainingMs = (key, now = Date.now()) => { var _a; return Math.max(0, ((_a = blockedUntilByKey.get(key)) !== null && _a !== void 0 ? _a : 0) - now); };
87
+ exports.rateLimitWaitRemainingMs = rateLimitWaitRemainingMs;
88
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
89
+ /** Wait out a recorded limit before calling `key`; throw (returning the wait) when it exceeds `maxWaitMs`. */
90
+ const waitForRateLimit = async (key, maxWaitMs) => {
91
+ const remaining = (0, exports.rateLimitWaitRemainingMs)(key);
92
+ if (remaining === 0)
93
+ return;
94
+ if (remaining > maxWaitMs)
95
+ throw new RateLimitedError(key, remaining);
96
+ await sleep(remaining);
97
+ };
98
+ exports.waitForRateLimit = waitForRateLimit;
99
+ /** Test seam: forget every recorded limit. */
100
+ const resetRateLimits = () => blockedUntilByKey.clear();
101
+ exports.resetRateLimits = resetRateLimits;
@@ -0,0 +1,49 @@
1
+ export interface CronLockTopology {
2
+ network: string;
3
+ nodeCode: string;
4
+ mintNodes: string[];
5
+ localDataCenter: string;
6
+ contactPoints: string[];
7
+ }
8
+ export interface CqlExecutor {
9
+ execute(query: string, params?: unknown[], options?: Record<string, unknown>): Promise<{
10
+ rows: unknown[];
11
+ }>;
12
+ }
13
+ export type AcquireResult = {
14
+ status: 'acquired';
15
+ token: string;
16
+ } | {
17
+ status: 'held';
18
+ } | {
19
+ status: 'peerUnavailable';
20
+ token: string;
21
+ message: string;
22
+ } | {
23
+ status: 'unavailable';
24
+ message: string;
25
+ };
26
+ /** A missing or incoherent topology is unsafe: never infer a one-node cluster. */
27
+ export declare const resolveTopology: (env?: NodeJS.ProcessEnv) => CronLockTopology;
28
+ /**
29
+ * Lease (TTL) of a held lock: how long a crashed holder blocks the next run. Callers pass the lease
30
+ * their job needs (it must exceed the job's maximum run time); the default honors
31
+ * STALE_EXECUTING_CRON_LOCK_MS, else 30 minutes.
32
+ */
33
+ export declare const leaseMs: (override?: number) => number;
34
+ export declare const parseOffline: (raw: string | undefined | null) => string[];
35
+ type Mode = 'serial' | 'localSerial' | 'standDown';
36
+ export declare const resolveMode: (offline: string[], topology: CronLockTopology) => Mode;
37
+ /** Only an UNAVAILABLE response proves that no SERIAL proposal was accepted. */
38
+ export declare const isPeerUnavailableError: (error: unknown) => boolean;
39
+ /**
40
+ * The directly testable LWT operation. Each invocation owns a unique token, so
41
+ * concurrent ticks on one node cannot both treat the node identity as ownership.
42
+ */
43
+ export declare const acquireWithExecutor: (executor: CqlExecutor, name: string, offline: string | null | undefined, topology: CronLockTopology, token?: string, lease?: number) => Promise<AcquireResult>;
44
+ export declare const acquire: (name: string, offline?: string | null, options?: {
45
+ leaseMs?: number;
46
+ }) => Promise<AcquireResult>;
47
+ /** Release only the exact acquisition token owned by this invocation. */
48
+ export declare const release: (name: string, token: string, offline?: string | null) => Promise<void>;
49
+ export {};
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.release = exports.acquire = exports.acquireWithExecutor = exports.isPeerUnavailableError = exports.resolveMode = exports.parseOffline = exports.leaseMs = exports.resolveTopology = void 0;
4
+ /**
5
+ * Cross-datacenter cron mutex for the self-hosted, multi-DC Scylla topology.
6
+ * (Moved from minting.handle.me src/helpers/cronLock/cqlCronLock.ts @ 6ecb9c3 so every box cron
7
+ * — minting.handle.me, hal-minting-engine — shares one implementation.)
8
+ *
9
+ * Minting runs active/active across boxes, each in its own Scylla datacenter with
10
+ * RF=1 per DC. Alternator conditional writes use LOCAL_SERIAL, so each DC can win
11
+ * independently. CQL SERIAL is the cross-DC mutex.
12
+ *
13
+ * Operators declare unavailable boxes through the offline set:
14
+ * - all boxes online: SERIAL / QUORUM;
15
+ * - this box offline: stand down;
16
+ * - this box is the only declared-online box: LOCAL_SERIAL / LOCAL_QUORUM;
17
+ * - an undeclared peer outage: the caller normally fails closed. StateData may
18
+ * use its DC-local fallback only for mintPaidSessions and only after a definite
19
+ * CQL UNAVAILABLE response (never after a timeout or unknown failure).
20
+ *
21
+ * The row TTL recovers crashed holders. Unique per-invocation owner tokens prevent
22
+ * overlapping ticks on one box from mistaking node identity for lock ownership.
23
+ */
24
+ const node_crypto_1 = require("node:crypto");
25
+ const cassandra_driver_1 = require("cassandra-driver");
26
+ const logger_1 = require("../logger");
27
+ const KEYSPACE = 'kora_locks';
28
+ const LOCK_TABLE = 'cron_lock';
29
+ const DEFAULT_LEASE_MS = 30 * 60 * 1000;
30
+ const required = (env, name) => {
31
+ var _a;
32
+ const value = (_a = env[name]) === null || _a === void 0 ? void 0 : _a.trim();
33
+ if (!value)
34
+ throw new Error(`${name} is required for cron-lock topology`);
35
+ return value;
36
+ };
37
+ const parseList = (value) => [...new Set(value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean))];
38
+ /** A missing or incoherent topology is unsafe: never infer a one-node cluster. */
39
+ const resolveTopology = (env = process.env) => {
40
+ const network = required(env, 'NETWORK').toLowerCase();
41
+ const nodeCode = required(env, 'KORA_NODE_CODE').toLowerCase();
42
+ const mintNodes = parseList(required(env, 'KORA_MINT_NODES'));
43
+ const localDataCenter = required(env, 'KORA_SCYLLA_DC');
44
+ const contactPoints = parseList(required(env, 'KORA_CQL_CONTACT_POINTS'));
45
+ if (!mintNodes.includes(nodeCode)) {
46
+ throw new Error(`KORA_NODE_CODE '${nodeCode}' is not present in KORA_MINT_NODES`);
47
+ }
48
+ if (contactPoints.length === 0) {
49
+ throw new Error('KORA_CQL_CONTACT_POINTS must contain at least one contact point');
50
+ }
51
+ return { network, nodeCode, mintNodes, localDataCenter, contactPoints };
52
+ };
53
+ exports.resolveTopology = resolveTopology;
54
+ /**
55
+ * Lease (TTL) of a held lock: how long a crashed holder blocks the next run. Callers pass the lease
56
+ * their job needs (it must exceed the job's maximum run time); the default honors
57
+ * STALE_EXECUTING_CRON_LOCK_MS, else 30 minutes.
58
+ */
59
+ const leaseMs = (override) => {
60
+ if (override !== undefined) {
61
+ if (!Number.isFinite(override) || override <= 0)
62
+ throw new Error(`Invalid cron-lock lease ${override}`);
63
+ return override;
64
+ }
65
+ const parsed = Number(process.env.STALE_EXECUTING_CRON_LOCK_MS);
66
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LEASE_MS;
67
+ };
68
+ exports.leaseMs = leaseMs;
69
+ const leaseSeconds = (override) => Math.ceil((0, exports.leaseMs)(override) / 1000);
70
+ const scopeName = (name, topology) => `${topology.network}:${name}`;
71
+ let client = null;
72
+ let clientTopologyKey = '';
73
+ let ensured = false;
74
+ const getClient = (topology) => {
75
+ const topologyKey = JSON.stringify([topology.contactPoints, topology.localDataCenter]);
76
+ if (!client || clientTopologyKey !== topologyKey) {
77
+ client = new cassandra_driver_1.Client({
78
+ contactPoints: topology.contactPoints,
79
+ localDataCenter: topology.localDataCenter,
80
+ socketOptions: { connectTimeout: 8000, readTimeout: 12000 }
81
+ });
82
+ clientTopologyKey = topologyKey;
83
+ ensured = false;
84
+ }
85
+ return client;
86
+ };
87
+ const ensureSchema = async (executor) => {
88
+ if (ensured)
89
+ return;
90
+ await executor.execute(`CREATE KEYSPACE IF NOT EXISTS ${KEYSPACE} WITH replication = ` +
91
+ `{'class':'NetworkTopologyStrategy','kora-dc1':1,'kora-dc2':1}`);
92
+ await executor.execute(`CREATE TABLE IF NOT EXISTS ${KEYSPACE}.${LOCK_TABLE} (name text PRIMARY KEY, owner text, acquired_at timestamp)`);
93
+ ensured = true;
94
+ };
95
+ const parseOffline = (raw) => {
96
+ const value = (raw !== null && raw !== void 0 ? raw : '').trim().toLowerCase();
97
+ if (!value || value === 'none')
98
+ return [];
99
+ return parseList(value);
100
+ };
101
+ exports.parseOffline = parseOffline;
102
+ const resolveMode = (offline, topology) => {
103
+ const unknownNodes = offline.filter((node) => !topology.mintNodes.includes(node));
104
+ if (unknownNodes.length > 0) {
105
+ throw new Error(`Offline set contains unknown mint node(s): ${unknownNodes.join(',')}`);
106
+ }
107
+ if (offline.includes(topology.nodeCode))
108
+ return 'standDown';
109
+ const online = topology.mintNodes.filter((node) => !offline.includes(node));
110
+ return online.length === 1 ? 'localSerial' : 'serial';
111
+ };
112
+ exports.resolveMode = resolveMode;
113
+ const createToken = (topology) => `${topology.nodeCode}:${(0, node_crypto_1.randomUUID)()}`;
114
+ /** Only an UNAVAILABLE response proves that no SERIAL proposal was accepted. */
115
+ const isPeerUnavailableError = (error) => error instanceof cassandra_driver_1.errors.ResponseError && error.code === cassandra_driver_1.types.responseErrorCodes.unavailableException;
116
+ exports.isPeerUnavailableError = isPeerUnavailableError;
117
+ /**
118
+ * The directly testable LWT operation. Each invocation owns a unique token, so
119
+ * concurrent ticks on one node cannot both treat the node identity as ownership.
120
+ */
121
+ const acquireWithExecutor = async (executor, name, offline, topology, token = createToken(topology), lease) => {
122
+ const mode = (0, exports.resolveMode)((0, exports.parseOffline)(offline), topology);
123
+ if (mode === 'standDown')
124
+ return { status: 'held' };
125
+ const serialConsistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localSerial : cassandra_driver_1.types.consistencies.serial;
126
+ const consistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localQuorum : cassandra_driver_1.types.consistencies.quorum;
127
+ const key = scopeName(name, topology);
128
+ try {
129
+ const result = await executor.execute(`INSERT INTO ${KEYSPACE}.${LOCK_TABLE} (name, owner, acquired_at) VALUES (?, ?, ?) IF NOT EXISTS USING TTL ${leaseSeconds(lease)}`, [key, token, new Date()], { prepare: true, serialConsistency, consistency });
130
+ const row = result.rows[0];
131
+ if ((row === null || row === void 0 ? void 0 : row['[applied]']) === true)
132
+ return { status: 'acquired', token };
133
+ if ((row === null || row === void 0 ? void 0 : row.owner) !== token)
134
+ return { status: 'held' };
135
+ const refresh = await executor.execute(`UPDATE ${KEYSPACE}.${LOCK_TABLE} USING TTL ${leaseSeconds(lease)} SET owner = ?, acquired_at = ? WHERE name = ? IF owner = ?`, [token, new Date(), key, token], { prepare: true, serialConsistency, consistency });
136
+ const refreshRow = refresh.rows[0];
137
+ return (refreshRow === null || refreshRow === void 0 ? void 0 : refreshRow['[applied]']) === true ? { status: 'acquired', token } : { status: 'held' };
138
+ }
139
+ catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ if (mode === 'serial' && (0, exports.isPeerUnavailableError)(error)) {
142
+ return { status: 'peerUnavailable', token, message };
143
+ }
144
+ return { status: 'unavailable', message };
145
+ }
146
+ };
147
+ exports.acquireWithExecutor = acquireWithExecutor;
148
+ const acquire = async (name, offline, options = {}) => {
149
+ try {
150
+ const topology = (0, exports.resolveTopology)();
151
+ const executor = getClient(topology);
152
+ await ensureSchema(executor);
153
+ const result = await (0, exports.acquireWithExecutor)(executor, name, offline, topology, undefined, options.leaseMs);
154
+ if (result.status === 'peerUnavailable') {
155
+ logger_1.Logger.log({
156
+ message: `cron lock '${name}' SERIAL unavailable because the configured peer quorum is unavailable: ${result.message}`,
157
+ event: 'cqlCronLock.acquire.peerUnavailable',
158
+ category: logger_1.LogCategory.WARN
159
+ });
160
+ }
161
+ else if (result.status === 'unavailable') {
162
+ logger_1.Logger.log({
163
+ message: `cron lock '${name}' failed without a positively identified peer-unavailable response: ${result.message}`,
164
+ event: 'cqlCronLock.acquire.unavailable',
165
+ category: logger_1.LogCategory.WARN
166
+ });
167
+ }
168
+ return result;
169
+ }
170
+ catch (error) {
171
+ logger_1.Logger.log({
172
+ message: `cron lock '${name}' is unavailable: ${error.message}`,
173
+ event: 'cqlCronLock.acquire.unavailable',
174
+ category: logger_1.LogCategory.WARN
175
+ });
176
+ return { status: 'unavailable', message: error.message };
177
+ }
178
+ };
179
+ exports.acquire = acquire;
180
+ /** Release only the exact acquisition token owned by this invocation. */
181
+ const release = async (name, token, offline) => {
182
+ try {
183
+ const topology = (0, exports.resolveTopology)();
184
+ const mode = (0, exports.resolveMode)((0, exports.parseOffline)(offline), topology);
185
+ if (mode === 'standDown')
186
+ return;
187
+ const serialConsistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localSerial : cassandra_driver_1.types.consistencies.serial;
188
+ const consistency = mode === 'localSerial' ? cassandra_driver_1.types.consistencies.localQuorum : cassandra_driver_1.types.consistencies.quorum;
189
+ await getClient(topology).execute(`DELETE FROM ${KEYSPACE}.${LOCK_TABLE} WHERE name = ? IF owner = ?`, [scopeName(name, topology), token], { prepare: true, serialConsistency, consistency });
190
+ }
191
+ catch (error) {
192
+ logger_1.Logger.log({
193
+ message: `cron lock '${name}' release best-effort failed (TTL will reclaim): ${error.message}`,
194
+ event: 'cqlCronLock.release.softfail',
195
+ category: logger_1.LogCategory.INFO
196
+ });
197
+ }
198
+ };
199
+ exports.release = release;
@@ -0,0 +1 @@
1
+ export * from './cqlCronLock';
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ // Subpath module: `@koralabs/kora-labs-common/cronLock`. Node-only (cassandra-driver, optional peer
18
+ // dependency). NOT re-exported from the root index.
19
+ __exportStar(require("./cqlCronLock"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koralabs/kora-labs-common",
3
- "version": "6.13.4",
3
+ "version": "6.14.0",
4
4
  "description": "Kora Labs Common Utilities",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -49,6 +49,7 @@
49
49
  "@typescript-eslint/eslint-plugin": "^8.67.0",
50
50
  "@typescript-eslint/parser": "^8.67.0",
51
51
  "bip39": "^3.1.0",
52
+ "cassandra-driver": "^4.10.0",
52
53
  "eslint": "^8.57.1",
53
54
  "jest": "^30.4.2",
54
55
  "ts-jest": "^29.4.11",
@@ -74,8 +75,9 @@
74
75
  "@aiken-lang/merkle-patricia-forestry": "^1.2.0",
75
76
  "@cardano-sdk/core": "^0.46.12 || ^0.47.0",
76
77
  "@cardano-sdk/crypto": "^0.4.5",
78
+ "@emurgo/cardano-message-signing-nodejs": "^1.1.0",
77
79
  "bip39": "^3.1.0",
78
- "@emurgo/cardano-message-signing-nodejs": "^1.1.0"
80
+ "cassandra-driver": "^4.9.0"
79
81
  },
80
82
  "peerDependenciesMeta": {
81
83
  "@aiken-lang/merkle-patricia-forestry": {
@@ -92,6 +94,9 @@
92
94
  },
93
95
  "@emurgo/cardano-message-signing-nodejs": {
94
96
  "optional": true
97
+ },
98
+ "cassandra-driver": {
99
+ "optional": true
95
100
  }
96
101
  }
97
102
  }
@@ -0,0 +1,66 @@
1
+ import { Cardano } from '@cardano-sdk/core';
2
+ import { ProviderRequestOptions } from '../chain/transport/fetchProviderJson';
3
+ import { ExUnits } from './fees';
4
+ import { RedeemerKey, ScriptTxProtocolParameters } from './scriptTx';
5
+ type BlockfrostAmount = {
6
+ unit: string;
7
+ quantity: string;
8
+ }[];
9
+ export interface BlockfrostScript {
10
+ hash: string;
11
+ language: Cardano.PlutusLanguageVersion | 'native';
12
+ /** Serialized size (the ledger's reference-script size). */
13
+ size: number;
14
+ script?: Cardano.Script;
15
+ }
16
+ export declare class BlockfrostEvaluationError extends Error {
17
+ readonly failure: unknown;
18
+ constructor(failure: unknown);
19
+ }
20
+ export declare const valueFromBlockfrost: (amount: BlockfrostAmount) => Cardano.Value;
21
+ /** Additional-UTxO shape Blockfrost's evaluator accepts: Ogmios value `{ ada: { lovelace }, [policy]: { [asset]: qty } }`. */
22
+ export declare const toOgmiosUtxo: ([txIn, txOut]: Cardano.Utxo) => ({
23
+ txId: any;
24
+ index: number;
25
+ } | {
26
+ datumHash?: any;
27
+ datum?: any;
28
+ address: Tagged<string, T>;
29
+ value: Record<string, Record<string, number>>;
30
+ txId?: undefined;
31
+ index?: undefined;
32
+ })[];
33
+ export declare class BlockfrostTxClient {
34
+ private readonly config;
35
+ private readonly scripts;
36
+ private readonly host;
37
+ constructor(config: {
38
+ network: string;
39
+ blockfrostApiKey: string;
40
+ fetcher?: ProviderRequestOptions['fetcher'];
41
+ });
42
+ private request;
43
+ private getOrNull;
44
+ getProtocolParameters(): Promise<ScriptTxProtocolParameters>;
45
+ getLatestBlock(): Promise<{
46
+ slot: number;
47
+ time: number;
48
+ hash: string;
49
+ height: number;
50
+ }>;
51
+ /** Slot of a POSIX time (ms), from the tip (1-second slots since Shelley on every network). */
52
+ slotAt(posixMs: number): Promise<number>;
53
+ getScript(hash: string): Promise<BlockfrostScript>;
54
+ private toUtxo;
55
+ /** Every UTxO at `address` (paginated). */
56
+ getAddressUtxos(address: string): Promise<Cardano.Utxo[]>;
57
+ /** UTxOs at `address` holding `unit`. */
58
+ getAddressAssetUtxos(address: string, unit: string): Promise<Cardano.Utxo[]>;
59
+ /** The UTxO `txHash#index`; throws `already spent` when it has been consumed. */
60
+ getUtxo(ref: string): Promise<Cardano.Utxo>;
61
+ /** Node (Ogmios) evaluation of every redeemer; `additionalUtxos` supplies inputs not yet on chain. */
62
+ evaluateTx(cbor: string, additionalUtxos?: Cardano.Utxo[]): Promise<Map<RedeemerKey, ExUnits>>;
63
+ /** Submit a signed tx; returns its hash. */
64
+ submitTx(cbor: string): Promise<string>;
65
+ }
66
+ export {};