@fluid-app/exigo-connection-sdk 0.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.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @fluid-app/exigo-connection-sdk
2
+
3
+ One way for Fluid droplets and Mist apps to talk to Exigo: a REST client, a
4
+ cached SQL pool, a credential interface and typed errors. The package owns
5
+ connection, auth and errors only. Business queries stay in each app.
6
+
7
+ ## Install
8
+
9
+ Public on npmjs:
10
+
11
+ ```bash
12
+ pnpm add @fluid-app/exigo-connection-sdk
13
+ # Only for apps that query the SQL replica:
14
+ pnpm add mssql
15
+ pnpm add -D @types/mssql
16
+ ```
17
+
18
+ | Import | Provides | Needs `mssql` |
19
+ | -------------------------------------- | -------------------------------------------------- | ------------- |
20
+ | `@fluid-app/exigo-connection-sdk` | `defineExigoCredentials`, credential types, errors | no |
21
+ | `@fluid-app/exigo-connection-sdk/rest` | `createExigoRestClient`, errors | no |
22
+ | `@fluid-app/exigo-connection-sdk/sql` | `getExigoPool`, `closeExigoPool`, errors | yes |
23
+
24
+ Runs on Node 20+: Cloud Run, and Vercel's Node runtime. Not on Edge.
25
+
26
+ ## 1. Credentials, once per app
27
+
28
+ The package reads no environment variables and stores nothing. The app says
29
+ where credentials come from; a missing field throws `ExigoCredentialsError`
30
+ naming it.
31
+
32
+ ```ts
33
+ // src/lib/exigo.ts — a multi-tenant droplet
34
+ import { defineExigoCredentials } from "@fluid-app/exigo-connection-sdk";
35
+
36
+ export const resolveExigoCredentials = defineExigoCredentials(
37
+ async (companyId: string) => {
38
+ const c = await prisma.credentials.findUniqueOrThrow({
39
+ where: { companyId },
40
+ });
41
+ return {
42
+ rest: {
43
+ baseUrl: c.exigoApiBaseUrl,
44
+ username: c.exigoApiUser,
45
+ password: c.exigoApiPassword,
46
+ company: c.exigoCompanyName,
47
+ },
48
+ sql: {
49
+ host: c.exigoDbHost,
50
+ database: c.exigoDbName,
51
+ username: c.exigoDbUsername,
52
+ password: c.exigoDbPassword,
53
+ // Required, no default: Exigo's replicas are Azure SQL, whose
54
+ // certificate does not match the host you connect to, so this must
55
+ // be true. It skips server certificate verification.
56
+ trustServerCertificate: true,
57
+ },
58
+ };
59
+ },
60
+ );
61
+ ```
62
+
63
+ A single-tenant Mist app returns its env vars from the same function instead.
64
+ The resolver runs on every call, so cache in the app if the lookup is costly.
65
+
66
+ ## 2. REST
67
+
68
+ ```ts
69
+ import {
70
+ createExigoRestClient,
71
+ ExigoAuthError,
72
+ ExigoTimeoutError,
73
+ } from "@fluid-app/exigo-connection-sdk/rest";
74
+
75
+ const { rest } = await resolveExigoCredentials(company.id);
76
+ const exigo = createExigoRestClient(rest);
77
+
78
+ try {
79
+ const account = await exigo.get<PointAccount>(
80
+ "/customers/{id}/pointaccounts/{accountId}",
81
+ { params: { id: customerId, accountId: pointAccountId } },
82
+ );
83
+ } catch (error) {
84
+ if (error instanceof ExigoAuthError) {
85
+ /* wrong credentials: tell the admin */
86
+ }
87
+ if (error instanceof ExigoTimeoutError) {
88
+ /* retry, or fail the callback fast */
89
+ }
90
+ throw error;
91
+ }
92
+ ```
93
+
94
+ The client:
95
+
96
+ - adds `/3.0` to the base URL, and does not double it when the stored URL
97
+ already has it; a base URL ending in another version, such as `/2.0`, throws
98
+ `ExigoCredentialsError` rather than pinning the client to it;
99
+ - requires an `https` base URL, since every request carries the password
100
+ (`http` only for `localhost`, for a local stub);
101
+ - sends Basic auth as `username@company`;
102
+ - times out after 15 s by default (`timeoutMs` on the client or per call, at
103
+ most 2,147,483,647 ms, the most `setTimeout` can hold);
104
+ - resolves to the parsed JSON, or `undefined` for an empty body;
105
+ - throws `ExigoApiError` for a non-2xx status, and for a 200 whose body is an
106
+ HTML page or not JSON, rather than handing the page back as data; the
107
+ password and Basic token are redacted from its message and `bodySnippet`;
108
+ - refuses a path that is an absolute URL, so the credentials only ever go to
109
+ the configured host, and a path that starts with an API version (`/3.0/...`),
110
+ since the client adds it;
111
+ - refuses a path param that is empty, `.` or `..`, which would otherwise
112
+ resolve to a different endpoint than the path names.
113
+
114
+ `get`, `delete` take `params`, `query`, `headers`, `timeoutMs` and `signal`;
115
+ `post`, `put`, `patch` also take a `body`, sent as JSON.
116
+
117
+ ## 3. SQL
118
+
119
+ ```ts
120
+ import { getExigoPool } from "@fluid-app/exigo-connection-sdk/sql";
121
+
122
+ const { sql: creds } = await resolveExigoCredentials(company.id);
123
+ const pool = await getExigoPool(creds);
124
+
125
+ const { recordset } = await pool
126
+ .request()
127
+ .input("email", email)
128
+ .query("SELECT CustomerID FROM Customers WHERE Email = @email");
129
+ ```
130
+
131
+ `getExigoPool` returns an `mssql` `ConnectionPool`, cached per host, port,
132
+ database and username, and within that per password. Repeated calls reuse
133
+ it, concurrent first calls share one connect, and a failed connect is not
134
+ cached. A rotated password opens its own pool without closing the previous
135
+ one, so requests in flight across a rotation keep working and a rollback
136
+ connects at once; the previous pool is closed once nothing has asked for it
137
+ in five minutes, and a database's current pool once nothing has asked for it
138
+ in 30. Pools are small (4 connections, closed after 10 s idle) because every running instance opens its own. Pass
139
+ `{ max, idleTimeoutMs, connectTimeoutMs, requestTimeoutMs }` as the second
140
+ argument to change that; options apply when the pool opens.
141
+
142
+ For typed parameters, import them from `mssql` itself
143
+ (`import sql from "mssql"; request.input("id", sql.Int, id)`).
144
+
145
+ Connect failures arrive as `ExigoAuthError`, `ExigoTimeoutError` or
146
+ `ExigoConnectionError`, with messages that say what to check. Errors from the
147
+ app's own queries are left alone; pass one through `translateExigoSqlError` to
148
+ map a timeout or a dropped connection the same way. A query error (`RequestError`)
149
+ comes back unchanged unless its code is a transport one (`ETIMEOUT`,
150
+ `ECONNCLOSED`, `ESOCKET`), so a bad table name is never reported as a network
151
+ failure.
152
+
153
+ Close pools on shutdown:
154
+
155
+ ```ts
156
+ import { closeAllExigoPools } from "@fluid-app/exigo-connection-sdk/sql";
157
+
158
+ process.once("SIGTERM", () => void closeAllExigoPools());
159
+ ```
160
+
161
+ In Next, keep `mssql` out of the bundle and off Edge:
162
+
163
+ ```ts
164
+ // next.config.ts
165
+ export default { serverExternalPackages: ["mssql"] };
166
+
167
+ // every route that queries
168
+ export const runtime = "nodejs";
169
+ ```
170
+
171
+ ## Errors
172
+
173
+ Every error extends `ExigoError`. None of them carries a password or an
174
+ Authorization header.
175
+
176
+ | Error | When |
177
+ | ----------------------- | ----------------------------------------------------- |
178
+ | `ExigoCredentialsError` | a field is missing or invalid (`fields` names them) |
179
+ | `ExigoAuthError` | REST 401/403, or a SQL login failure |
180
+ | `ExigoTimeoutError` | a REST call, SQL connect or SQL query ran too long |
181
+ | `ExigoConnectionError` | DNS, refused connection, TLS, network |
182
+ | `ExigoApiError` | REST answered with an error or a body that isn't JSON |
183
+
184
+ `source` is `"rest"` or `"sql"` on the auth, timeout and connection errors.
185
+
186
+ ## Releasing
187
+
188
+ Listed in `.github/workflows/frontend-publish-rep-packages.yml`: a merge to
189
+ `main` that changes this package publishes it to npm, bumping the patch
190
+ version when the current one is already published.
@@ -0,0 +1,49 @@
1
+ //#region src/credentials.d.ts
2
+ interface ExigoRestCredentials {
3
+ /**
4
+ * The API host, such as `https://acme-api.exigo.com`. A trailing `/3.0` is
5
+ * accepted and not doubled; without one the client adds it.
6
+ */
7
+ readonly baseUrl: string;
8
+ readonly username: string;
9
+ readonly password: string;
10
+ /** The Exigo company name; Basic auth sends `username@company`. */
11
+ readonly company: string;
12
+ }
13
+ interface ExigoSqlCredentials {
14
+ readonly host: string;
15
+ readonly database: string;
16
+ readonly username: string;
17
+ readonly password: string;
18
+ /** Defaults to 1433. */
19
+ readonly port?: number;
20
+ /** Defaults to true. */
21
+ readonly encrypt?: boolean;
22
+ /**
23
+ * Required, with no default: whether to accept the server's certificate
24
+ * without verifying it. Exigo's replicas are Azure SQL, whose certificate
25
+ * does not match the host apps connect to, so they need `true`; that skips
26
+ * server identity checks, so each app makes the decision explicitly.
27
+ */
28
+ readonly trustServerCertificate: boolean;
29
+ }
30
+ /** What a resolver returns. An app includes the interfaces it uses. */
31
+ interface ExigoCredentials {
32
+ readonly rest?: ExigoRestCredentials;
33
+ readonly sql?: ExigoSqlCredentials;
34
+ }
35
+ type ExigoCredentialResolver<Key, Credentials extends ExigoCredentials> = (key: Key) => Credentials | null | undefined | Promise<Credentials | null | undefined>;
36
+ /** Throws `ExigoCredentialsError` naming every missing REST field. */
37
+ declare function assertExigoRestCredentials(value: unknown): asserts value is ExigoRestCredentials;
38
+ /** Throws `ExigoCredentialsError` naming every missing or invalid SQL field. */
39
+ declare function assertExigoSqlCredentials(value: unknown): asserts value is ExigoSqlCredentials;
40
+ /**
41
+ * Wraps an app's credential lookup so every caller gets validated credentials.
42
+ *
43
+ * The resolver runs on every call; cache in the app if the lookup is costly.
44
+ * Each interface the result includes is checked in full, and a result with
45
+ * neither, or none at all, throws.
46
+ */
47
+ declare function defineExigoCredentials<Key = string, Credentials extends ExigoCredentials = ExigoCredentials>(resolver: ExigoCredentialResolver<Key, Credentials>): (key: Key) => Promise<Credentials>;
48
+ //#endregion
49
+ export { ExigoCredentialResolver, ExigoCredentials, ExigoRestCredentials, ExigoSqlCredentials, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
@@ -0,0 +1,92 @@
1
+ import { ExigoCredentialsError } from "./errors.mjs";
2
+ import { z } from "zod";
3
+ //#region src/credentials.ts
4
+ /**
5
+ * The credential interface.
6
+ *
7
+ * The package never reads environment variables and stores nothing. Each app
8
+ * supplies credentials, or a resolver that finds them: env in a single-tenant
9
+ * Mist app, a table in a multi-tenant droplet. There is deliberately no
10
+ * fallback — the `RAIN_*` fallback that pointed a droplet at another client's
11
+ * Exigo came from exactly that — so a missing field throws.
12
+ */
13
+ /** Present and not blank. Values from a database arrive as `null` when unset. */
14
+ const requiredString = z.string().refine((value) => value.trim() !== "");
15
+ const restCredentialsSchema = z.object({
16
+ baseUrl: requiredString,
17
+ username: requiredString,
18
+ password: requiredString,
19
+ company: requiredString
20
+ });
21
+ const sqlCredentialsSchema = z.object({
22
+ host: requiredString,
23
+ database: requiredString,
24
+ username: requiredString,
25
+ password: requiredString,
26
+ port: z.number().int().min(1).max(65535).optional(),
27
+ encrypt: z.boolean().optional(),
28
+ trustServerCertificate: z.boolean()
29
+ });
30
+ const REQUIRED_FIELDS = {
31
+ rest: Object.keys(restCredentialsSchema.shape),
32
+ sql: [
33
+ "host",
34
+ "database",
35
+ "username",
36
+ "password",
37
+ "trustServerCertificate"
38
+ ]
39
+ };
40
+ /**
41
+ * Validates one section and throws `ExigoCredentialsError` naming every field
42
+ * at fault. Zod's own messages are never used: they can quote the value, and
43
+ * these values are secrets.
44
+ */
45
+ function assertSection(section, schema, value) {
46
+ const result = schema.safeParse(value);
47
+ if (result.success) return;
48
+ const missing = [];
49
+ const invalid = [];
50
+ const required = REQUIRED_FIELDS[section];
51
+ for (const issue of result.error.issues) {
52
+ const [field] = issue.path;
53
+ if (field === void 0) {
54
+ missing.push(...required.map((name) => `${section}.${name}`));
55
+ continue;
56
+ }
57
+ const name = `${section}.${String(field)}`;
58
+ const list = required.includes(String(field)) ? missing : invalid;
59
+ if (!list.includes(name)) list.push(name);
60
+ }
61
+ const label = `Exigo ${section.toUpperCase()} credentials`;
62
+ const parts = [missing.length > 0 ? `are missing ${missing.join(", ")}` : null, invalid.length > 0 ? `have an invalid ${invalid.join(", ")}` : null].filter((part) => part !== null);
63
+ const portHint = invalid.includes("sql.port") ? "; sql.port must be an integer from 1 to 65535" : "";
64
+ const trustHint = missing.includes("sql.trustServerCertificate") ? "; set sql.trustServerCertificate explicitly (Exigo's Azure SQL replicas need true, which skips certificate verification)" : "";
65
+ throw new ExigoCredentialsError(`${label} ${parts.join(" and ")}${portHint}${trustHint}.`, [...missing, ...invalid]);
66
+ }
67
+ /** Throws `ExigoCredentialsError` naming every missing REST field. */
68
+ function assertExigoRestCredentials(value) {
69
+ assertSection("rest", restCredentialsSchema, value);
70
+ }
71
+ /** Throws `ExigoCredentialsError` naming every missing or invalid SQL field. */
72
+ function assertExigoSqlCredentials(value) {
73
+ assertSection("sql", sqlCredentialsSchema, value);
74
+ }
75
+ /**
76
+ * Wraps an app's credential lookup so every caller gets validated credentials.
77
+ *
78
+ * The resolver runs on every call; cache in the app if the lookup is costly.
79
+ * Each interface the result includes is checked in full, and a result with
80
+ * neither, or none at all, throws.
81
+ */
82
+ function defineExigoCredentials(resolver) {
83
+ return async (key) => {
84
+ const credentials = await resolver(key);
85
+ if (!credentials || !credentials.rest && !credentials.sql) throw new ExigoCredentialsError("The Exigo credential resolver returned no REST or SQL credentials.");
86
+ if (credentials.rest) assertExigoRestCredentials(credentials.rest);
87
+ if (credentials.sql) assertExigoSqlCredentials(credentials.sql);
88
+ return credentials;
89
+ };
90
+ }
91
+ //#endregion
92
+ export { assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
@@ -0,0 +1,79 @@
1
+ //#region src/errors.d.ts
2
+ /**
3
+ * Typed errors for everything this package does.
4
+ *
5
+ * Every error is an `ExigoError`, so an app can catch the package's failures
6
+ * as one family and branch on the subclass when the remedy differs: an
7
+ * `ExigoAuthError` is for the admin who entered the credentials, an
8
+ * `ExigoTimeoutError` is worth retrying, an `ExigoConnectionError` usually
9
+ * means a wrong host or an IP allowlist.
10
+ *
11
+ * Messages never carry a password or an Authorization header.
12
+ */
13
+ /** Which of Exigo's two interfaces an error came from. */
14
+ type ExigoSource = "rest" | "sql";
15
+ interface ExigoErrorOptions {
16
+ readonly cause?: unknown;
17
+ }
18
+ declare class ExigoError extends Error {
19
+ name: string;
20
+ constructor(message: string, options?: ExigoErrorOptions);
21
+ }
22
+ /** Credentials were missing a field or held a value that cannot work. */
23
+ declare class ExigoCredentialsError extends ExigoError {
24
+ name: string;
25
+ /** Dotted names of the fields at fault, such as `rest.password`. */
26
+ readonly fields: readonly string[];
27
+ constructor(message: string, fields?: readonly string[]);
28
+ }
29
+ /** Exigo refused the credentials: REST 401/403, or a SQL login failure. */
30
+ declare class ExigoAuthError extends ExigoError {
31
+ name: string;
32
+ readonly source: ExigoSource;
33
+ /** The HTTP status, for REST. */
34
+ readonly status: number | undefined;
35
+ constructor(message: string, source: ExigoSource, options?: ExigoErrorOptions & {
36
+ readonly status?: number;
37
+ });
38
+ }
39
+ /** A REST call or a SQL connect or query ran past its timeout. */
40
+ declare class ExigoTimeoutError extends ExigoError {
41
+ name: string;
42
+ readonly source: ExigoSource;
43
+ /** The limit that was exceeded, when known. */
44
+ readonly timeoutMs: number | undefined;
45
+ constructor(message: string, source: ExigoSource, options?: ExigoErrorOptions & {
46
+ readonly timeoutMs?: number;
47
+ });
48
+ }
49
+ /** Exigo could not be reached: DNS, refused connection, TLS, network. */
50
+ declare class ExigoConnectionError extends ExigoError {
51
+ name: string;
52
+ readonly source: ExigoSource;
53
+ constructor(message: string, source: ExigoSource, options?: ExigoErrorOptions);
54
+ }
55
+ interface ExigoApiErrorDetails {
56
+ readonly status: number;
57
+ readonly method: string;
58
+ /** The request path below the API version, without the query string. */
59
+ readonly path: string;
60
+ readonly contentType: string | null;
61
+ /** The start of the response body, for diagnostics. */
62
+ readonly bodySnippet: string;
63
+ }
64
+ /**
65
+ * The REST API answered, but not with a usable result: a non-2xx status other
66
+ * than an auth refusal, or a success whose body is not JSON (Exigo serves an
67
+ * HTML page for a wrong URL with a 200).
68
+ */
69
+ declare class ExigoApiError extends ExigoError {
70
+ name: string;
71
+ readonly status: number;
72
+ readonly method: string;
73
+ readonly path: string;
74
+ readonly contentType: string | null;
75
+ readonly bodySnippet: string;
76
+ constructor(message: string, details: ExigoApiErrorDetails);
77
+ }
78
+ //#endregion
79
+ export { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError };
@@ -0,0 +1,73 @@
1
+ //#region src/errors.ts
2
+ var ExigoError = class extends Error {
3
+ name = "ExigoError";
4
+ constructor(message, options) {
5
+ super(message, options?.cause === void 0 ? void 0 : options);
6
+ }
7
+ };
8
+ /** Credentials were missing a field or held a value that cannot work. */
9
+ var ExigoCredentialsError = class extends ExigoError {
10
+ name = "ExigoCredentialsError";
11
+ /** Dotted names of the fields at fault, such as `rest.password`. */
12
+ fields;
13
+ constructor(message, fields = []) {
14
+ super(message);
15
+ this.fields = fields;
16
+ }
17
+ };
18
+ /** Exigo refused the credentials: REST 401/403, or a SQL login failure. */
19
+ var ExigoAuthError = class extends ExigoError {
20
+ name = "ExigoAuthError";
21
+ source;
22
+ /** The HTTP status, for REST. */
23
+ status;
24
+ constructor(message, source, options) {
25
+ super(message, options);
26
+ this.source = source;
27
+ this.status = options?.status;
28
+ }
29
+ };
30
+ /** A REST call or a SQL connect or query ran past its timeout. */
31
+ var ExigoTimeoutError = class extends ExigoError {
32
+ name = "ExigoTimeoutError";
33
+ source;
34
+ /** The limit that was exceeded, when known. */
35
+ timeoutMs;
36
+ constructor(message, source, options) {
37
+ super(message, options);
38
+ this.source = source;
39
+ this.timeoutMs = options?.timeoutMs;
40
+ }
41
+ };
42
+ /** Exigo could not be reached: DNS, refused connection, TLS, network. */
43
+ var ExigoConnectionError = class extends ExigoError {
44
+ name = "ExigoConnectionError";
45
+ source;
46
+ constructor(message, source, options) {
47
+ super(message, options);
48
+ this.source = source;
49
+ }
50
+ };
51
+ /**
52
+ * The REST API answered, but not with a usable result: a non-2xx status other
53
+ * than an auth refusal, or a success whose body is not JSON (Exigo serves an
54
+ * HTML page for a wrong URL with a 200).
55
+ */
56
+ var ExigoApiError = class extends ExigoError {
57
+ name = "ExigoApiError";
58
+ status;
59
+ method;
60
+ path;
61
+ contentType;
62
+ bodySnippet;
63
+ constructor(message, details) {
64
+ super(message);
65
+ this.status = details.status;
66
+ this.method = details.method;
67
+ this.path = details.path;
68
+ this.contentType = details.contentType;
69
+ this.bodySnippet = details.bodySnippet;
70
+ }
71
+ };
72
+ //#endregion
73
+ export { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError };
@@ -0,0 +1,3 @@
1
+ import { ExigoCredentialResolver, ExigoCredentials, ExigoRestCredentials, ExigoSqlCredentials, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials } from "./credentials.mjs";
2
+ import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
3
+ export { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, type ExigoCredentialResolver, type ExigoCredentials, ExigoCredentialsError, ExigoError, ExigoErrorOptions, type ExigoRestCredentials, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError } from "./errors.mjs";
2
+ import { assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials } from "./credentials.mjs";
3
+ export { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError, assertExigoRestCredentials, assertExigoSqlCredentials, defineExigoCredentials };
@@ -0,0 +1,65 @@
1
+ import { ExigoRestCredentials } from "./credentials.mjs";
2
+ import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
3
+
4
+ //#region src/rest.d.ts
5
+ declare const EXIGO_API_VERSION = "3.0";
6
+ declare const DEFAULT_EXIGO_TIMEOUT_MS = 15e3;
7
+ type ExigoHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
8
+ type ExigoQueryValue = string | number | boolean;
9
+ interface ExigoRequestOptions {
10
+ /** Values for `{name}` placeholders in the path, URL-encoded. */
11
+ readonly params?: Readonly<Record<string, string | number>>;
12
+ /** Query string values; `null` and `undefined` are left out. */
13
+ readonly query?: Readonly<Record<string, ExigoQueryValue | readonly ExigoQueryValue[] | null | undefined>>;
14
+ /** Sent as JSON. */
15
+ readonly body?: unknown;
16
+ readonly headers?: Readonly<Record<string, string>>;
17
+ /** Overrides the client's timeout for this call. */
18
+ readonly timeoutMs?: number;
19
+ /** Aborting it rejects with the signal's reason, not an Exigo error. */
20
+ readonly signal?: AbortSignal;
21
+ }
22
+ type ExigoMethodOptions = Omit<ExigoRequestOptions, "body">;
23
+ type ExigoBodyMethodOptions = ExigoRequestOptions;
24
+ interface ExigoRestClientOptions {
25
+ /** Per-request timeout. Defaults to {@link DEFAULT_EXIGO_TIMEOUT_MS}. */
26
+ readonly timeoutMs?: number;
27
+ /** For tests and instrumentation. Defaults to the global `fetch`. */
28
+ readonly fetch?: typeof fetch;
29
+ }
30
+ interface ExigoRestClient {
31
+ /** The normalized base URL, including the API version. */
32
+ readonly baseUrl: string;
33
+ /**
34
+ * Resolves to the parsed JSON body, or `undefined` for an empty one. The
35
+ * type parameter is an assertion about Exigo's response, not a check.
36
+ */
37
+ request<T = unknown>(method: ExigoHttpMethod, path: string, options?: ExigoRequestOptions): Promise<T>;
38
+ get<T = unknown>(path: string, options?: ExigoMethodOptions): Promise<T>;
39
+ post<T = unknown>(path: string, options?: ExigoBodyMethodOptions): Promise<T>;
40
+ put<T = unknown>(path: string, options?: ExigoBodyMethodOptions): Promise<T>;
41
+ patch<T = unknown>(path: string, options?: ExigoBodyMethodOptions): Promise<T>;
42
+ delete<T = unknown>(path: string, options?: ExigoMethodOptions): Promise<T>;
43
+ }
44
+ /**
45
+ * `https://acme-api.exigo.com`, `…/3.0` and `…/3.0/` all become
46
+ * `https://acme-api.exigo.com/3.0`. A base URL ending in any other version
47
+ * segment is refused rather than kept, so no stored URL can pin a client to
48
+ * an API version it was not written for. Query strings and fragments are
49
+ * dropped.
50
+ *
51
+ * Requires HTTPS, since every request carries the password in its
52
+ * Authorization header; plain HTTP is accepted only for a loopback host, such
53
+ * as a local stub.
54
+ */
55
+ declare function normalizeExigoBaseUrl(baseUrl: string): string;
56
+ /**
57
+ * Creates a client for one set of REST credentials.
58
+ *
59
+ * Accepts `undefined` so a resolver's optional `rest` section can be passed
60
+ * straight in: missing credentials throw `ExigoCredentialsError` here rather
61
+ * than failing later as an unauthenticated request.
62
+ */
63
+ declare function createExigoRestClient(credentials: ExigoRestCredentials | null | undefined, options?: ExigoRestClientOptions): ExigoRestClient;
64
+ //#endregion
65
+ export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoBodyMethodOptions, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoHttpMethod, ExigoMethodOptions, ExigoQueryValue, ExigoRequestOptions, ExigoRestClient, ExigoRestClientOptions, type ExigoRestCredentials, ExigoSource, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
package/dist/rest.mjs ADDED
@@ -0,0 +1,231 @@
1
+ import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError } from "./errors.mjs";
2
+ import { assertExigoRestCredentials } from "./credentials.mjs";
3
+ //#region src/rest.ts
4
+ /**
5
+ * `@fluid-app/exigo-connection-sdk/rest`: one way to call Exigo's REST API.
6
+ *
7
+ * The client owns what every droplet used to rebuild: the `/3.0` version
8
+ * segment (a URL without it answered 200 with no data, #846), Basic auth as
9
+ * `username@company`, a default timeout, and reading Exigo's responses without
10
+ * mistaking its HTML error pages for data (#751). Endpoints and payloads stay
11
+ * in each app.
12
+ *
13
+ * Uses the global `fetch`, so it runs on Node 20+ (Cloud Run, Vercel's Node
14
+ * runtime) without Node-only APIs.
15
+ */
16
+ const EXIGO_API_VERSION = "3.0";
17
+ const DEFAULT_EXIGO_TIMEOUT_MS = 15e3;
18
+ const BODY_SNIPPET_LENGTH = 500;
19
+ const VERSION_SEGMENT = /^\d+\.\d+$/;
20
+ const LOOPBACK_HOSTS = new Set([
21
+ "localhost",
22
+ "127.0.0.1",
23
+ "[::1]"
24
+ ]);
25
+ /**
26
+ * `https://acme-api.exigo.com`, `…/3.0` and `…/3.0/` all become
27
+ * `https://acme-api.exigo.com/3.0`. A base URL ending in any other version
28
+ * segment is refused rather than kept, so no stored URL can pin a client to
29
+ * an API version it was not written for. Query strings and fragments are
30
+ * dropped.
31
+ *
32
+ * Requires HTTPS, since every request carries the password in its
33
+ * Authorization header; plain HTTP is accepted only for a loopback host, such
34
+ * as a local stub.
35
+ */
36
+ function normalizeExigoBaseUrl(baseUrl) {
37
+ let url;
38
+ try {
39
+ url = new URL(baseUrl.trim());
40
+ } catch {
41
+ throw new ExigoCredentialsError("Exigo rest.baseUrl is not a valid URL.", ["rest.baseUrl"]);
42
+ }
43
+ const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
44
+ if (url.protocol !== "https:" && !loopbackHttp) throw new ExigoCredentialsError("Exigo rest.baseUrl must be an https URL; requests carry the password.", ["rest.baseUrl"]);
45
+ if (url.username || url.password) throw new ExigoCredentialsError("Exigo rest.baseUrl must not carry credentials; pass username and password instead.", ["rest.baseUrl"]);
46
+ const segments = url.pathname.split("/").filter(Boolean);
47
+ const last = segments.at(-1);
48
+ if (last !== void 0 && VERSION_SEGMENT.test(last)) {
49
+ if (last !== "3.0") throw new ExigoCredentialsError(`Exigo rest.baseUrl ends in /${last}; this client speaks the 3.0 API. Store the host alone or with /3.0.`, ["rest.baseUrl"]);
50
+ } else segments.push("3.0");
51
+ return `${url.origin}/${segments.join("/")}`;
52
+ }
53
+ /** Latin-1-safe Base64 of UTF-8 text, without Node's Buffer. */
54
+ function base64(text) {
55
+ let binary = "";
56
+ for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
57
+ return btoa(binary);
58
+ }
59
+ function authorizationHeader(credentials) {
60
+ const suffix = `@${credentials.company}`;
61
+ return `Basic ${base64(`${credentials.username.endsWith(suffix) ? credentials.username : `${credentials.username}${suffix}`}:${credentials.password}`)}`;
62
+ }
63
+ const ABSOLUTE_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
64
+ const LEADING_VERSION_SEGMENT = /^\/?\d+\.\d+(?:\/|$)/;
65
+ function expandPath(path, params) {
66
+ if (ABSOLUTE_URL.test(path)) throw new TypeError(`Exigo request path must be relative to the base URL, got "${path}".`);
67
+ if (LEADING_VERSION_SEGMENT.test(path)) throw new TypeError(`Exigo request path must not start with an API version; the client adds /3.0. Got "${path}".`);
68
+ const expanded = path.replace(/\{([^}]+)\}/g, (_, name) => {
69
+ const value = params?.[name];
70
+ if (value === void 0) throw new TypeError(`Missing path parameter "${name}" for ${path}.`);
71
+ const text = String(value);
72
+ if (text === "" || text === "." || text === "..") throw new TypeError(`Path parameter "${name}" is empty or a dot segment for ${path}.`);
73
+ return encodeURIComponent(text);
74
+ });
75
+ return expanded.startsWith("/") ? expanded : `/${expanded}`;
76
+ }
77
+ function appendQuery(url, query) {
78
+ for (const [key, value] of Object.entries(query ?? {})) {
79
+ if (value === null || value === void 0) continue;
80
+ const values = Array.isArray(value) ? value : [value];
81
+ for (const item of values) url.searchParams.append(key, String(item));
82
+ }
83
+ }
84
+ const REDACTED = "[redacted]";
85
+ /**
86
+ * The start of a response body for diagnostics, with the credentials taken
87
+ * out first: an Exigo error body can echo the request, and the snippet ends up
88
+ * in the error's message and in whatever logs it.
89
+ */
90
+ function redact(text, secrets) {
91
+ let redacted = text;
92
+ for (const secret of secrets) if (secret.length > 0) redacted = redacted.split(secret).join(REDACTED);
93
+ return redacted;
94
+ }
95
+ function snippet(text, secrets) {
96
+ const collapsed = redact(text, secrets).replace(/\s+/g, " ").trim();
97
+ return collapsed.length > BODY_SNIPPET_LENGTH ? `${collapsed.slice(0, BODY_SNIPPET_LENGTH)}…` : collapsed;
98
+ }
99
+ function looksLikeHtml(contentType, text) {
100
+ return /\btext\/html\b/i.test(contentType ?? "") || /^\s*<(?:!doctype\s+html|html)[\s>]/i.test(text);
101
+ }
102
+ function parseJson(text) {
103
+ try {
104
+ return {
105
+ ok: true,
106
+ value: JSON.parse(text)
107
+ };
108
+ } catch {
109
+ return { ok: false };
110
+ }
111
+ }
112
+ /** Exigo's error bodies vary by endpoint; take the first message they carry. */
113
+ function errorMessageFrom(body) {
114
+ if (body === null || typeof body !== "object") return null;
115
+ const record = body;
116
+ for (const key of [
117
+ "message",
118
+ "Message",
119
+ "error_description",
120
+ "error",
121
+ "title"
122
+ ]) {
123
+ const value = record[key];
124
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
125
+ }
126
+ return null;
127
+ }
128
+ function describeNetworkFailure(error) {
129
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error;
130
+ const code = cause !== null && typeof cause === "object" ? cause.code : void 0;
131
+ const message = cause instanceof Error ? cause.message : String(cause);
132
+ return typeof code === "string" && !message.includes(code) ? `${code}: ${message}` : message;
133
+ }
134
+ const MAX_TIMEOUT_MS = 2147483647;
135
+ function validTimeout(timeoutMs) {
136
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) throw new TypeError(`Exigo timeoutMs must be a positive number up to ${MAX_TIMEOUT_MS}, got ${timeoutMs}.`);
137
+ return timeoutMs;
138
+ }
139
+ /**
140
+ * Creates a client for one set of REST credentials.
141
+ *
142
+ * Accepts `undefined` so a resolver's optional `rest` section can be passed
143
+ * straight in: missing credentials throw `ExigoCredentialsError` here rather
144
+ * than failing later as an unauthenticated request.
145
+ */
146
+ function createExigoRestClient(credentials, options = {}) {
147
+ if (!credentials) throw new ExigoCredentialsError("No Exigo REST credentials were provided.", ["rest"]);
148
+ assertExigoRestCredentials(credentials);
149
+ const baseUrl = normalizeExigoBaseUrl(credentials.baseUrl);
150
+ const host = new URL(baseUrl).host;
151
+ const authorization = authorizationHeader(credentials);
152
+ const secrets = [credentials.password, authorization.replace(/^Basic /, "")];
153
+ const defaultTimeout = validTimeout(options.timeoutMs ?? 15e3);
154
+ const fetchImpl = options.fetch ?? globalThis.fetch;
155
+ async function request(method, path, requestOptions = {}) {
156
+ const relative = expandPath(path, requestOptions.params);
157
+ const url = new URL(`${baseUrl}${relative}`);
158
+ appendQuery(url, requestOptions.query);
159
+ const timeoutMs = validTimeout(requestOptions.timeoutMs ?? defaultTimeout);
160
+ const headers = new Headers(requestOptions.headers);
161
+ if (!headers.has("Accept")) headers.set("Accept", "application/json");
162
+ headers.set("Authorization", authorization);
163
+ let body;
164
+ if (requestOptions.body !== void 0) {
165
+ headers.set("Content-Type", "application/json");
166
+ body = JSON.stringify(requestOptions.body);
167
+ }
168
+ const callerSignal = requestOptions.signal;
169
+ callerSignal?.throwIfAborted();
170
+ const controller = new AbortController();
171
+ let timedOut = false;
172
+ const timer = setTimeout(() => {
173
+ timedOut = true;
174
+ controller.abort();
175
+ }, timeoutMs);
176
+ const onCallerAbort = () => controller.abort(callerSignal?.reason);
177
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
178
+ let response;
179
+ let text;
180
+ try {
181
+ response = await fetchImpl(url, {
182
+ method,
183
+ headers,
184
+ body,
185
+ signal: controller.signal
186
+ });
187
+ text = await response.text();
188
+ } catch (error) {
189
+ if (timedOut) throw new ExigoTimeoutError(`Exigo ${method} ${relative} did not answer within ${timeoutMs} ms.`, "rest", {
190
+ cause: error,
191
+ timeoutMs
192
+ });
193
+ if (callerSignal?.aborted) throw callerSignal.reason;
194
+ throw new ExigoConnectionError(`Could not reach Exigo at ${host}: ${describeNetworkFailure(error)}`, "rest", { cause: error });
195
+ } finally {
196
+ clearTimeout(timer);
197
+ callerSignal?.removeEventListener("abort", onCallerAbort);
198
+ }
199
+ const contentType = response.headers.get("content-type");
200
+ const details = {
201
+ status: response.status,
202
+ method,
203
+ path: url.pathname.slice(new URL(baseUrl).pathname.length) || "/",
204
+ contentType,
205
+ bodySnippet: snippet(text, secrets)
206
+ };
207
+ const html = looksLikeHtml(contentType, text);
208
+ const parsed = html || text.trim() === "" ? null : parseJson(text);
209
+ if (response.status === 401 || response.status === 403) throw new ExigoAuthError(`Exigo rejected the API credentials (HTTP ${response.status}). Check the username, password and company name.`, "rest", { status: response.status });
210
+ if (!response.ok) {
211
+ const exigoMessage = parsed?.ok ? errorMessageFrom(parsed.value) : null;
212
+ const reason = exigoMessage ? `: ${redact(exigoMessage, secrets)}` : html ? " with an HTML error page instead of JSON" : "";
213
+ throw new ExigoApiError(`Exigo ${method} ${details.path} failed with HTTP ${response.status}${reason}.`, details);
214
+ }
215
+ if (html) throw new ExigoApiError(`Exigo ${method} ${details.path} answered HTTP ${response.status} with an HTML page, not JSON. The base URL is probably not Exigo's API host.`, details);
216
+ if (parsed === null) return void 0;
217
+ if (!parsed.ok) throw new ExigoApiError(`Exigo ${method} ${details.path} answered HTTP ${response.status} with a body that is not JSON.`, details);
218
+ return parsed.value;
219
+ }
220
+ return {
221
+ baseUrl,
222
+ request,
223
+ get: (path, requestOptions) => request("GET", path, requestOptions),
224
+ post: (path, requestOptions) => request("POST", path, requestOptions),
225
+ put: (path, requestOptions) => request("PUT", path, requestOptions),
226
+ patch: (path, requestOptions) => request("PATCH", path, requestOptions),
227
+ delete: (path, requestOptions) => request("DELETE", path, requestOptions)
228
+ };
229
+ }
230
+ //#endregion
231
+ export { DEFAULT_EXIGO_TIMEOUT_MS, EXIGO_API_VERSION, ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError, createExigoRestClient, normalizeExigoBaseUrl };
package/dist/sql.d.mts ADDED
@@ -0,0 +1,53 @@
1
+ import { ExigoSqlCredentials } from "./credentials.mjs";
2
+ import { ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoSource, ExigoTimeoutError } from "./errors.mjs";
3
+ import { ConnectionPool, ConnectionPool as ConnectionPool$1 } from "mssql";
4
+
5
+ //#region src/sql.d.ts
6
+ declare const EXIGO_SQL_DEFAULTS: {
7
+ readonly port: number;
8
+ readonly max: number;
9
+ readonly idleTimeoutMs: number;
10
+ readonly connectTimeoutMs: number;
11
+ readonly requestTimeoutMs: number;
12
+ };
13
+ /**
14
+ * Pool settings. They apply when the pool opens; a later call with different
15
+ * options reuses the open pool as it is.
16
+ */
17
+ interface ExigoPoolOptions {
18
+ readonly max?: number;
19
+ readonly idleTimeoutMs?: number;
20
+ readonly connectTimeoutMs?: number;
21
+ readonly requestTimeoutMs?: number;
22
+ /**
23
+ * Errors the pool raises outside a query, such as an idle connection that
24
+ * dropped. Defaults to a `console.warn`; the pool replaces the connection.
25
+ */
26
+ readonly onError?: (error: ExigoError | Error) => void;
27
+ }
28
+ /**
29
+ * Turns an `mssql` failure into the package's typed errors where Exigo's
30
+ * remedy is known: login, timeout, and reaching the server. Anything else,
31
+ * such as a syntax error in a droplet's query, is returned unchanged.
32
+ */
33
+ declare function translateExigoSqlError(error: unknown): unknown;
34
+ /**
35
+ * The open pool for these credentials, opening it on first use.
36
+ *
37
+ * Pools are keyed by host, port, database and username, and within that by
38
+ * password and TLS settings. Concurrent first calls share one connect. A
39
+ * failed connect is not cached, so the next call tries again. A rotated
40
+ * password opens its own pool without closing the previous one, which is
41
+ * closed once no call has used it for five minutes. A pool closed directly is
42
+ * replaced on the next call.
43
+ */
44
+ declare function getExigoPool(credentials: ExigoSqlCredentials | null | undefined, options?: ExigoPoolOptions): Promise<ConnectionPool$1>;
45
+ /**
46
+ * Closes and forgets every pool for this database, whatever password it was
47
+ * opened with.
48
+ */
49
+ declare function closeExigoPool(credentials: Pick<ExigoSqlCredentials, "host" | "database" | "username" | "port">): Promise<void>;
50
+ /** Closes every pool this package opened; for shutdown hooks and tests. */
51
+ declare function closeAllExigoPools(): Promise<void>;
52
+ //#endregion
53
+ export { type ConnectionPool, EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoApiErrorDetails, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoErrorOptions, ExigoPoolOptions, ExigoSource, type ExigoSqlCredentials, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
package/dist/sql.mjs ADDED
@@ -0,0 +1,295 @@
1
+ import { ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError } from "./errors.mjs";
2
+ import { assertExigoSqlCredentials } from "./credentials.mjs";
3
+ import { createHash } from "node:crypto";
4
+ //#region src/sql.ts
5
+ /**
6
+ * `@fluid-app/exigo-connection-sdk/sql`: one cached connection pool per Exigo database.
7
+ *
8
+ * Opening a pool per query leaks connections past the request that opened
9
+ * them (rewards-exigo's checkout path did this). Here the first call for a
10
+ * database opens a small pool, later calls reuse it, and the app closes it
11
+ * explicitly on shutdown. The cache lives on `globalThis`, so Next's dev
12
+ * reloads share one pool rather than stacking new ones.
13
+ *
14
+ * Node runtime only. `mssql` is an optional peer dependency that is imported
15
+ * the first time a pool opens, so an app that only imports `./rest` never
16
+ * loads it. In Next, list `mssql` in `serverExternalPackages` and set
17
+ * `export const runtime = "nodejs"` on routes that query.
18
+ */
19
+ const EXIGO_SQL_DEFAULTS = {
20
+ port: 1433,
21
+ max: 4,
22
+ idleTimeoutMs: 1e4,
23
+ connectTimeoutMs: 12e3,
24
+ requestTimeoutMs: 3e4
25
+ };
26
+ /**
27
+ * Each database holds one pool per credential fingerprint (password and TLS
28
+ * settings). A rotation therefore never closes a pool another request is
29
+ * using: requests that resolved the old password keep the old pool, new ones
30
+ * get the new pool, and a rollback finds its pool still there. A pool that
31
+ * is not its database's most recently used one, and that no request has asked
32
+ * for in this long, is closed and forgotten. The sweep runs on every call and,
33
+ * while any database holds more than one pool, on an unref'd timer, so it
34
+ * happens even for a tenant that stops querying.
35
+ */
36
+ const UNUSED_POOL_TTL_MS = 5 * 6e4;
37
+ /**
38
+ * A database's current pool is closed too once nothing has asked for it in
39
+ * this long, so tenant churn does not grow the cache. It already holds no
40
+ * connections when idle (`min: 0`); the next call reopens it.
41
+ */
42
+ const IDLE_POOL_TTL_MS = 30 * 6e4;
43
+ const CACHE_KEY = Symbol.for("@fluid-app/exigo-connection-sdk/sql-pools");
44
+ const SWEEP_TIMER_KEY = Symbol.for("@fluid-app/exigo-connection-sdk/sql-pool-sweep");
45
+ function cache() {
46
+ const holder = globalThis;
47
+ holder[CACHE_KEY] ??= /* @__PURE__ */ new Map();
48
+ return holder[CACHE_KEY];
49
+ }
50
+ const TRANSPORT_REQUEST_CODES = new Set([
51
+ "ETIMEOUT",
52
+ "ECONNCLOSED",
53
+ "ESOCKET"
54
+ ]);
55
+ function poolKey(credentials) {
56
+ const port = credentials.port ?? EXIGO_SQL_DEFAULTS.port;
57
+ return JSON.stringify([
58
+ credentials.host.trim().toLowerCase(),
59
+ port,
60
+ credentials.database,
61
+ credentials.username
62
+ ]);
63
+ }
64
+ function fingerprint(credentials) {
65
+ return createHash("sha256").update(JSON.stringify([
66
+ credentials.password,
67
+ credentials.encrypt ?? true,
68
+ credentials.trustServerCertificate
69
+ ])).digest("hex");
70
+ }
71
+ function toMssqlConfig(credentials, options) {
72
+ return {
73
+ server: credentials.host.trim(),
74
+ port: credentials.port ?? EXIGO_SQL_DEFAULTS.port,
75
+ database: credentials.database,
76
+ user: credentials.username,
77
+ password: credentials.password,
78
+ connectionTimeout: options.connectTimeoutMs ?? EXIGO_SQL_DEFAULTS.connectTimeoutMs,
79
+ requestTimeout: options.requestTimeoutMs ?? EXIGO_SQL_DEFAULTS.requestTimeoutMs,
80
+ pool: {
81
+ max: options.max ?? EXIGO_SQL_DEFAULTS.max,
82
+ min: 0,
83
+ idleTimeoutMillis: options.idleTimeoutMs ?? EXIGO_SQL_DEFAULTS.idleTimeoutMs
84
+ },
85
+ options: {
86
+ encrypt: credentials.encrypt ?? true,
87
+ trustServerCertificate: credentials.trustServerCertificate,
88
+ enableArithAbort: true
89
+ }
90
+ };
91
+ }
92
+ async function loadMssql() {
93
+ let loaded;
94
+ try {
95
+ loaded = await import("mssql");
96
+ } catch (error) {
97
+ throw new ExigoError("@fluid-app/exigo-connection-sdk/sql needs the mssql package. Install it next to @fluid-app/exigo-connection-sdk.", { cause: error });
98
+ }
99
+ return loaded.default ?? loaded;
100
+ }
101
+ /**
102
+ * mssql wraps tedious, which wraps the socket error, sometimes in an
103
+ * AggregateError: `ECONNREFUSED` sits three levels below a top-level
104
+ * `ESOCKET` whose message only says "Could not connect (sequence)".
105
+ */
106
+ function errorChain(error) {
107
+ const codes = /* @__PURE__ */ new Set();
108
+ const messages = [];
109
+ const seen = /* @__PURE__ */ new Set();
110
+ const visit = (value, depth) => {
111
+ if (value === null || typeof value !== "object" || seen.has(value) || depth > 5) return;
112
+ seen.add(value);
113
+ const record = value;
114
+ if (typeof record.code === "string") codes.add(record.code);
115
+ if (typeof record.message === "string") messages.push(record.message);
116
+ visit(record.originalError, depth + 1);
117
+ visit(record.cause, depth + 1);
118
+ if (Array.isArray(record.errors)) for (const inner of record.errors) visit(inner, depth + 1);
119
+ };
120
+ visit(error, 0);
121
+ return {
122
+ codes,
123
+ text: messages.join("\n")
124
+ };
125
+ }
126
+ /**
127
+ * Turns an `mssql` failure into the package's typed errors where Exigo's
128
+ * remedy is known: login, timeout, and reaching the server. Anything else,
129
+ * such as a syntax error in a droplet's query, is returned unchanged.
130
+ */
131
+ function translateExigoSqlError(error) {
132
+ if (error instanceof ExigoError) return error;
133
+ const { codes, text } = errorChain(error);
134
+ const code = error !== null && typeof error === "object" ? error.code : void 0;
135
+ const name = error instanceof Error ? error.name : "";
136
+ const raw = error instanceof Error ? error.message : String(error);
137
+ const options = { cause: error };
138
+ const has = (...candidates) => candidates.some((candidate) => codes.has(candidate));
139
+ if (name === "RequestError" && !TRANSPORT_REQUEST_CODES.has(String(code))) return error;
140
+ if (has("ELOGIN") || /Login failed/i.test(text)) return new ExigoAuthError("Exigo's SQL Server rejected the username or password.", "sql", options);
141
+ if (code === "ETIMEOUT") return name === "RequestError" ? new ExigoTimeoutError(`An Exigo SQL query timed out: ${raw}`, "sql", options) : new ExigoTimeoutError("Timed out connecting to Exigo's SQL Server. This is usually an IP allowlist or a wrong host or port, not a bad password.", "sql", options);
142
+ if (has("ENOTFOUND", "EAI_AGAIN", "EINSTLOOKUP") || /ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(text)) return new ExigoConnectionError(`Could not resolve the Exigo SQL host: ${raw}`, "sql", options);
143
+ if (has("ECONNREFUSED") || /ECONNREFUSED/i.test(text)) return new ExigoConnectionError("The Exigo SQL host refused the connection. Check the port and that the server accepts remote TCP connections.", "sql", options);
144
+ if (/self[- ]signed|certificate/i.test(text)) return new ExigoConnectionError(`TLS failed reaching Exigo's SQL Server: ${raw}`, "sql", options);
145
+ if (name === "ConnectionError" || code === "ESOCKET" || code === "ECONNCLOSED") return new ExigoConnectionError(`Could not connect to Exigo's SQL Server: ${raw}`, "sql", options);
146
+ return error;
147
+ }
148
+ async function openPool(credentials, options) {
149
+ const pool = new (await (loadMssql())).ConnectionPool(toMssqlConfig(credentials, options));
150
+ const onError = options.onError ?? ((error) => console.warn(`[@fluid-app/exigo-connection-sdk] ${error.message}`));
151
+ pool.on("error", (error) => {
152
+ const translated = translateExigoSqlError(error);
153
+ onError(translated instanceof Error ? translated : new Error(String(translated)));
154
+ });
155
+ try {
156
+ return await pool.connect();
157
+ } catch (error) {
158
+ await pool.close().catch(() => void 0);
159
+ throw translateExigoSqlError(error);
160
+ }
161
+ }
162
+ function isUsable(pool) {
163
+ return pool.connected || pool.connecting;
164
+ }
165
+ /**
166
+ * The open pool for these credentials, opening it on first use.
167
+ *
168
+ * Pools are keyed by host, port, database and username, and within that by
169
+ * password and TLS settings. Concurrent first calls share one connect. A
170
+ * failed connect is not cached, so the next call tries again. A rotated
171
+ * password opens its own pool without closing the previous one, which is
172
+ * closed once no call has used it for five minutes. A pool closed directly is
173
+ * replaced on the next call.
174
+ */
175
+ async function getExigoPool(credentials, options = {}) {
176
+ if (!credentials) throw new ExigoCredentialsError("No Exigo SQL credentials were provided.", ["sql"]);
177
+ assertExigoSqlCredentials(credentials);
178
+ const pools = cache();
179
+ const identity = poolKey(credentials);
180
+ const current = fingerprint(credentials);
181
+ const group = () => {
182
+ let entries = pools.get(identity);
183
+ if (entries === void 0) {
184
+ entries = /* @__PURE__ */ new Map();
185
+ pools.set(identity, entries);
186
+ }
187
+ return entries;
188
+ };
189
+ const own = pools.get(identity)?.get(current);
190
+ if (own !== void 0) own.lastUsed = Date.now();
191
+ sweepSupersededPools(Date.now());
192
+ for (;;) {
193
+ const entries = group();
194
+ const existing = entries.get(current);
195
+ if (existing === void 0) break;
196
+ existing.lastUsed = Date.now();
197
+ const pool = await existing.pool;
198
+ if (isUsable(pool)) return pool;
199
+ if (entries.get(current) === existing) {
200
+ entries.delete(current);
201
+ break;
202
+ }
203
+ }
204
+ const entry = {
205
+ pool: openPool(credentials, options),
206
+ lastUsed: Date.now()
207
+ };
208
+ const entries = group();
209
+ entries.set(current, entry);
210
+ entry.pool.catch(() => {
211
+ if (entries.get(current) === entry) entries.delete(current);
212
+ });
213
+ if (entries.size > 1) scheduleSweep();
214
+ return entry.pool;
215
+ }
216
+ /**
217
+ * Closes every pool that has been superseded on its database (it is not the
218
+ * most recently used one there) and has sat unused past the TTL. A database's
219
+ * current pool is never closed here, however long it idles.
220
+ */
221
+ function sweepSupersededPools(now) {
222
+ const pools = cache();
223
+ let pending = false;
224
+ for (const [identity, entries] of pools) {
225
+ if (entries.size > 1) {
226
+ let keep;
227
+ let newest = Number.NEGATIVE_INFINITY;
228
+ for (const [key, entry] of entries) if (entry.lastUsed >= newest) {
229
+ newest = entry.lastUsed;
230
+ keep = key;
231
+ }
232
+ for (const [key, entry] of entries) {
233
+ if (key === keep || now - entry.lastUsed < UNUSED_POOL_TTL_MS) continue;
234
+ entries.delete(key);
235
+ closeEntry(entry).catch(() => void 0);
236
+ }
237
+ }
238
+ for (const [key, entry] of entries) {
239
+ if (now - entry.lastUsed < IDLE_POOL_TTL_MS) continue;
240
+ entries.delete(key);
241
+ closeEntry(entry).catch(() => void 0);
242
+ }
243
+ if (entries.size === 0) pools.delete(identity);
244
+ else if (entries.size > 1) pending = true;
245
+ }
246
+ if (pending) scheduleSweep();
247
+ }
248
+ /** One timer at a time; unref'd so it never keeps a process alive. */
249
+ function scheduleSweep() {
250
+ const holder = globalThis;
251
+ if (holder[SWEEP_TIMER_KEY] !== void 0) return;
252
+ const timer = setTimeout(() => {
253
+ holder[SWEEP_TIMER_KEY] = void 0;
254
+ sweepSupersededPools(Date.now());
255
+ }, UNUSED_POOL_TTL_MS);
256
+ timer.unref?.();
257
+ holder[SWEEP_TIMER_KEY] = timer;
258
+ }
259
+ function cancelSweep() {
260
+ const holder = globalThis;
261
+ if (holder[SWEEP_TIMER_KEY] === void 0) return;
262
+ clearTimeout(holder[SWEEP_TIMER_KEY]);
263
+ holder[SWEEP_TIMER_KEY] = void 0;
264
+ }
265
+ /**
266
+ * Closes and forgets every pool for this database, whatever password it was
267
+ * opened with.
268
+ */
269
+ async function closeExigoPool(credentials) {
270
+ const pools = cache();
271
+ const identity = poolKey(credentials);
272
+ const entries = pools.get(identity);
273
+ if (entries === void 0) return;
274
+ pools.delete(identity);
275
+ await Promise.all([...entries.values()].map(closeEntry));
276
+ }
277
+ /** Closes every pool this package opened; for shutdown hooks and tests. */
278
+ async function closeAllExigoPools() {
279
+ const pools = cache();
280
+ const entries = [...pools.values()].flatMap((group) => [...group.values()]);
281
+ pools.clear();
282
+ cancelSweep();
283
+ await Promise.all(entries.map(closeEntry));
284
+ }
285
+ async function closeEntry(entry) {
286
+ let pool;
287
+ try {
288
+ pool = await entry.pool;
289
+ } catch {
290
+ return;
291
+ }
292
+ await pool.close();
293
+ }
294
+ //#endregion
295
+ export { EXIGO_SQL_DEFAULTS, ExigoApiError, ExigoAuthError, ExigoConnectionError, ExigoCredentialsError, ExigoError, ExigoTimeoutError, closeAllExigoPools, closeExigoPool, getExigoPool, translateExigoSqlError };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@fluid-app/exigo-connection-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Exigo REST client, SQL pool and credential interface for Fluid droplets and Mist apps",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/fluid-commerce/fluid.git",
9
+ "directory": "packages/platform/exigo"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.mts",
19
+ "default": "./dist/index.mjs"
20
+ },
21
+ "./rest": {
22
+ "types": "./dist/rest.d.mts",
23
+ "default": "./dist/rest.mjs"
24
+ },
25
+ "./sql": {
26
+ "types": "./dist/sql.d.mts",
27
+ "default": "./dist/sql.mjs"
28
+ }
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org"
33
+ },
34
+ "dependencies": {
35
+ "zod": "4.6.5"
36
+ },
37
+ "devDependencies": {
38
+ "@fluid-app/typescript-config": "0.0.0",
39
+ "@types/mssql": "^12.3.0",
40
+ "@types/node": "24.10.12",
41
+ "mssql": "^12.7.2",
42
+ "tsdown": "^0.21.0",
43
+ "typescript": "^5",
44
+ "vitest": "^4.0.18"
45
+ },
46
+ "peerDependencies": {
47
+ "@types/mssql": ">=9",
48
+ "mssql": ">=11 <13"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@types/mssql": {
52
+ "optional": true
53
+ },
54
+ "mssql": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "dev": "tsdown --watch",
61
+ "lint": "oxlint --deny-warnings",
62
+ "lint:fix": "oxlint --fix --deny-warnings",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest",
65
+ "typecheck": "tsgo --noEmit",
66
+ "check:publish-shape": "node scripts/check-publish-shape.mjs"
67
+ }
68
+ }