@homeflare/seat-runtime 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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +508 -0
- package/dist/index.js.map +21 -0
- package/dist/mcp-connect.d.ts +72 -0
- package/dist/mcp-connect.d.ts.map +1 -0
- package/dist/mcp-error.d.ts +77 -0
- package/dist/mcp-error.d.ts.map +1 -0
- package/dist/mcp-pages.d.ts +18 -0
- package/dist/mcp-pages.d.ts.map +1 -0
- package/dist/mcp-render.d.ts +25 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-tool.d.ts +61 -0
- package/dist/mcp-tool.d.ts.map +1 -0
- package/dist/mcp-toolkit.d.ts +61 -0
- package/dist/mcp-toolkit.d.ts.map +1 -0
- package/dist/mcp-toolset.d.ts +33 -0
- package/dist/mcp-toolset.d.ts.map +1 -0
- package/dist/rounds.d.ts +108 -0
- package/dist/rounds.d.ts.map +1 -0
- package/dist/seat-model.d.ts +59 -0
- package/dist/seat-model.d.ts.map +1 -0
- package/dist/seat-obs.d.ts +23 -0
- package/dist/seat-obs.d.ts.map +1 -0
- package/dist/seat-state.d.ts +33 -0
- package/dist/seat-state.d.ts.map +1 -0
- package/dist/stamp.d.ts +23 -0
- package/dist/stamp.d.ts.map +1 -0
- package/dist/state-dsn.d.ts +41 -0
- package/dist/state-dsn.d.ts.map +1 -0
- package/dist/state-postgres.d.ts +58 -0
- package/dist/state-postgres.d.ts.map +1 -0
- package/dist/state-valkey-connection.d.ts +49 -0
- package/dist/state-valkey-connection.d.ts.map +1 -0
- package/dist/state-valkey-scrub.d.ts +37 -0
- package/dist/state-valkey-scrub.d.ts.map +1 -0
- package/dist/state-valkey-send.d.ts +35 -0
- package/dist/state-valkey-send.d.ts.map +1 -0
- package/dist/state-valkey.d.ts +83 -0
- package/dist/state-valkey.d.ts.map +1 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +327 -0
- package/dist/state.js.map +16 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/docs/mcp.md +40 -0
- package/docs/pairing.md +39 -0
- package/docs/state.md +174 -0
- package/package.json +45 -0
- package/src/index.ts +25 -0
- package/src/mcp-connect.ts +172 -0
- package/src/mcp-error.ts +150 -0
- package/src/mcp-pages.ts +37 -0
- package/src/mcp-render.ts +67 -0
- package/src/mcp-tool.ts +101 -0
- package/src/mcp-toolkit.ts +183 -0
- package/src/mcp-toolset.ts +91 -0
- package/src/rounds.ts +211 -0
- package/src/seat-model.ts +94 -0
- package/src/seat-obs.ts +103 -0
- package/src/seat-state.ts +53 -0
- package/src/stamp.ts +88 -0
- package/src/state-dsn.ts +112 -0
- package/src/state-postgres.ts +114 -0
- package/src/state-valkey-connection.ts +113 -0
- package/src/state-valkey-scrub.ts +60 -0
- package/src/state-valkey-send.ts +67 -0
- package/src/state-valkey.ts +193 -0
- package/src/state.ts +8 -0
- package/src/version.ts +2 -0
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@homeflare/seat-runtime/state` — a seat's Postgres and Valkey state, on its own subpath.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ NOT THE ROOT ENTRY, ON PURPOSE. The root stays runtime-neutral (`fetch` only). This one holds
|
|
5
|
+
* `node:net` through `@effect/sql-pg` and `Bun.RedisClient`, so a consumer opts in to it
|
|
6
|
+
* explicitly, the way the kit's other host-specific code is reached.
|
|
7
|
+
*/
|
|
8
|
+
export * as SeatState from './seat-state.ts';
|
|
9
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAC"}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
// src/seat-state.ts
|
|
19
|
+
var exports_seat_state = {};
|
|
20
|
+
__export(exports_seat_state, {
|
|
21
|
+
DEFAULT_COMMAND_TIMEOUT: () => DEFAULT_COMMAND_TIMEOUT,
|
|
22
|
+
DEFAULT_CONNECTION_TIMEOUT: () => DEFAULT_CONNECTION_TIMEOUT,
|
|
23
|
+
POSTGRES_URL_VARIABLE: () => POSTGRES_URL_VARIABLE,
|
|
24
|
+
PgClient: () => PgClient2,
|
|
25
|
+
VALKEY_URL_VARIABLE: () => VALKEY_URL_VARIABLE,
|
|
26
|
+
isPermissionDenied: () => isPermissionDenied,
|
|
27
|
+
layer: () => layer,
|
|
28
|
+
layerFromEnv: () => layerFromEnv,
|
|
29
|
+
postgres: () => postgres,
|
|
30
|
+
postgresFromEnv: () => postgresFromEnv,
|
|
31
|
+
valkey: () => valkey,
|
|
32
|
+
valkeyFromEnv: () => valkeyFromEnv
|
|
33
|
+
});
|
|
34
|
+
import * as Layer3 from "effect/Layer";
|
|
35
|
+
|
|
36
|
+
// src/state-postgres.ts
|
|
37
|
+
import { PgClient } from "@effect/sql-pg";
|
|
38
|
+
import * as Config from "effect/Config";
|
|
39
|
+
import * as Duration from "effect/Duration";
|
|
40
|
+
import * as Effect from "effect/Effect";
|
|
41
|
+
import * as Layer from "effect/Layer";
|
|
42
|
+
import * as Redacted2 from "effect/Redacted";
|
|
43
|
+
import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError";
|
|
44
|
+
|
|
45
|
+
// src/state-dsn.ts
|
|
46
|
+
var OVERRIDING_KEYS = new Set(["host", "port", "user", "dbname"]);
|
|
47
|
+
function decode(value) {
|
|
48
|
+
try {
|
|
49
|
+
return decodeURIComponent(value);
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function parse(raw) {
|
|
55
|
+
try {
|
|
56
|
+
return new URL(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function postgresFields(dsn) {
|
|
62
|
+
const url = parse(dsn);
|
|
63
|
+
if (url === undefined)
|
|
64
|
+
return;
|
|
65
|
+
for (const key of url.searchParams.keys())
|
|
66
|
+
if (OVERRIDING_KEYS.has(key))
|
|
67
|
+
return {};
|
|
68
|
+
const hostname = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : decode(url.hostname);
|
|
69
|
+
const database = decode(url.pathname.replace(/^\//, ""));
|
|
70
|
+
const username = decode(url.username);
|
|
71
|
+
const port = url.port === "" ? undefined : Number(url.port);
|
|
72
|
+
if (hostname === undefined || database === undefined || username === undefined)
|
|
73
|
+
return {};
|
|
74
|
+
if (port !== undefined && !(Number.isInteger(port) && port >= 1 && port <= 65535))
|
|
75
|
+
return {};
|
|
76
|
+
return {
|
|
77
|
+
host: hostname === "" ? undefined : hostname,
|
|
78
|
+
port,
|
|
79
|
+
database: database === "" ? undefined : database,
|
|
80
|
+
username: username === "" ? undefined : username
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
var VALKEY_SCHEMES = new Set([
|
|
84
|
+
"redis:",
|
|
85
|
+
"valkey:",
|
|
86
|
+
"rediss:",
|
|
87
|
+
"valkeys:",
|
|
88
|
+
"redis+tls:",
|
|
89
|
+
"redis+unix:",
|
|
90
|
+
"redis+tls+unix:"
|
|
91
|
+
]);
|
|
92
|
+
function valkeyFields(raw) {
|
|
93
|
+
const url = parse(raw);
|
|
94
|
+
if (url === undefined || !VALKEY_SCHEMES.has(url.protocol))
|
|
95
|
+
return;
|
|
96
|
+
if (url.protocol.endsWith("+unix:"))
|
|
97
|
+
return {};
|
|
98
|
+
const host = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
|
|
99
|
+
const database = url.pathname.replace(/^\//, "");
|
|
100
|
+
return {
|
|
101
|
+
host: host === "" ? undefined : host,
|
|
102
|
+
port: url.port === "" ? 6379 : Number(url.port),
|
|
103
|
+
database: database === "" ? undefined : database
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/state-postgres.ts
|
|
108
|
+
var POSTGRES_URL_VARIABLE = "SEAT_POSTGRES_URL";
|
|
109
|
+
var DEFAULT_CONNECT_TIMEOUT = Duration.seconds(5);
|
|
110
|
+
var refused = (message) => new SqlError({
|
|
111
|
+
reason: new ConnectionError({
|
|
112
|
+
cause: new Error(message),
|
|
113
|
+
message: `SeatState postgres: ${message}`,
|
|
114
|
+
operation: "connect"
|
|
115
|
+
})
|
|
116
|
+
});
|
|
117
|
+
var acquire = Effect.fnUntraced(function* (options) {
|
|
118
|
+
const dsn = Redacted2.value(typeof options.url === "string" ? Redacted2.make(options.url) : options.url);
|
|
119
|
+
const fields = postgresFields(dsn);
|
|
120
|
+
if (fields === undefined)
|
|
121
|
+
return yield* refused("the URL is not a postgres:// URL");
|
|
122
|
+
const connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT;
|
|
123
|
+
const client = yield* PgClient.make({
|
|
124
|
+
url: Redacted2.make(dsn),
|
|
125
|
+
...fields,
|
|
126
|
+
maxConnections: options.maxConnections,
|
|
127
|
+
connectTimeout,
|
|
128
|
+
applicationName: options.applicationName
|
|
129
|
+
});
|
|
130
|
+
yield* client`select 1`.pipe(Effect.timeoutOrElse({
|
|
131
|
+
duration: connectTimeout,
|
|
132
|
+
orElse: () => Effect.fail(refused(`did not answer within ${String(Duration.toMillis(connectTimeout))} ms`))
|
|
133
|
+
}));
|
|
134
|
+
return client;
|
|
135
|
+
});
|
|
136
|
+
var postgres = (options) => PgClient.layerFrom(Effect.suspend(() => acquire(options)));
|
|
137
|
+
var postgresFromEnv = (options) => {
|
|
138
|
+
const { variable, ...rest } = options ?? {};
|
|
139
|
+
return Layer.unwrap(Config.Redacted(variable ?? POSTGRES_URL_VARIABLE).pipe(Effect.map((url) => postgres({ ...rest, url }))));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/state-valkey.ts
|
|
143
|
+
import * as Config2 from "effect/Config";
|
|
144
|
+
import * as Duration3 from "effect/Duration";
|
|
145
|
+
import * as Effect4 from "effect/Effect";
|
|
146
|
+
import * as Layer2 from "effect/Layer";
|
|
147
|
+
import * as Redacted4 from "effect/Redacted";
|
|
148
|
+
import * as Redis2 from "effect/unstable/persistence/Redis";
|
|
149
|
+
|
|
150
|
+
// src/state-valkey-connection.ts
|
|
151
|
+
import * as Duration2 from "effect/Duration";
|
|
152
|
+
import * as Effect2 from "effect/Effect";
|
|
153
|
+
import * as Queue from "effect/Queue";
|
|
154
|
+
var DEFAULT_FIRST_PAUSE_MS = 250;
|
|
155
|
+
var DEFAULT_LONGEST_PAUSE_MS = 5000;
|
|
156
|
+
var keepConnected = Effect2.fnUntraced(function* (client, options) {
|
|
157
|
+
const wake = yield* Queue.sliding(1);
|
|
158
|
+
const firstPause = Duration2.toMillis(options.firstPause ?? DEFAULT_FIRST_PAUSE_MS);
|
|
159
|
+
const longestPause = Duration2.toMillis(options.longestPause ?? DEFAULT_LONGEST_PAUSE_MS);
|
|
160
|
+
let connecting;
|
|
161
|
+
const connectOnce = () => {
|
|
162
|
+
connecting ??= client.connect().finally(() => {
|
|
163
|
+
connecting = undefined;
|
|
164
|
+
});
|
|
165
|
+
return connecting;
|
|
166
|
+
};
|
|
167
|
+
const attempt = Effect2.tryPromise({ try: connectOnce, catch: (cause) => cause }).pipe(Effect2.timeoutOrElse({
|
|
168
|
+
duration: options.connectionTimeout,
|
|
169
|
+
orElse: () => Effect2.fail(new Error("a reconnect attempt did not finish in time"))
|
|
170
|
+
}), Effect2.withSpan("valkey reconnect", { kind: "client", attributes: options.attributes }), Effect2.as(true), Effect2.orElseSucceed(() => false));
|
|
171
|
+
const reconnect = Effect2.gen(function* () {
|
|
172
|
+
if (client.connected)
|
|
173
|
+
return;
|
|
174
|
+
yield* Effect2.logWarning("SeatState valkey: the connection is down, reconnecting");
|
|
175
|
+
let pause = firstPause;
|
|
176
|
+
let attempts = 0;
|
|
177
|
+
while (!client.connected) {
|
|
178
|
+
attempts += 1;
|
|
179
|
+
yield* attempt;
|
|
180
|
+
if (client.connected)
|
|
181
|
+
break;
|
|
182
|
+
yield* Effect2.sleep(pause);
|
|
183
|
+
pause = Math.min(pause * 2, longestPause);
|
|
184
|
+
}
|
|
185
|
+
yield* Effect2.logInfo(`SeatState valkey: reconnected after ${String(attempts)} attempt(s)`);
|
|
186
|
+
});
|
|
187
|
+
client.onclose = () => {
|
|
188
|
+
Queue.offerUnsafe(wake, undefined);
|
|
189
|
+
};
|
|
190
|
+
yield* Effect2.addFinalizer(() => Effect2.sync(() => {
|
|
191
|
+
client.onclose = () => {};
|
|
192
|
+
}));
|
|
193
|
+
yield* Effect2.forkScoped(Effect2.forever(Queue.take(wake).pipe(Effect2.andThen(reconnect))));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// src/state-valkey-send.ts
|
|
197
|
+
import * as Effect3 from "effect/Effect";
|
|
198
|
+
import * as Redis from "effect/unstable/persistence/Redis";
|
|
199
|
+
|
|
200
|
+
// src/state-valkey-scrub.ts
|
|
201
|
+
var OMITTED = "[arguments omitted]";
|
|
202
|
+
function cutArguments(message, command) {
|
|
203
|
+
const name = command.toLowerCase();
|
|
204
|
+
let at = message.indexOf("'");
|
|
205
|
+
while (at !== -1) {
|
|
206
|
+
const close = message.indexOf("'", at + 1);
|
|
207
|
+
const quoted = close === -1 ? undefined : message.slice(at + 1, close).toLowerCase();
|
|
208
|
+
if (quoted === undefined || !(quoted === name || quoted.startsWith(`${name}|`))) {
|
|
209
|
+
return `${message.slice(0, at)}${OMITTED}`;
|
|
210
|
+
}
|
|
211
|
+
at = message.indexOf("'", close + 1);
|
|
212
|
+
}
|
|
213
|
+
return message;
|
|
214
|
+
}
|
|
215
|
+
function scrubbedError(cause, command) {
|
|
216
|
+
const original = cause instanceof Error ? cause : undefined;
|
|
217
|
+
const scrubbed = new Error(cutArguments(original?.message ?? String(cause), command));
|
|
218
|
+
if (original !== undefined) {
|
|
219
|
+
scrubbed.name = original.name;
|
|
220
|
+
const code = original.code;
|
|
221
|
+
if (typeof code === "string")
|
|
222
|
+
Object.assign(scrubbed, { code });
|
|
223
|
+
}
|
|
224
|
+
return scrubbed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/state-valkey-send.ts
|
|
228
|
+
function instrumentedSend(client, options) {
|
|
229
|
+
return (command, ...args) => {
|
|
230
|
+
const name = command.toUpperCase();
|
|
231
|
+
return Effect3.tryPromise({
|
|
232
|
+
try: () => client.send(command, [...args]),
|
|
233
|
+
catch: (cause) => new Redis.RedisError({ cause: scrubbedError(cause, command) })
|
|
234
|
+
}).pipe(Effect3.timeoutOrElse({
|
|
235
|
+
duration: options.commandTimeout,
|
|
236
|
+
orElse: () => Effect3.fail(new Redis.RedisError({
|
|
237
|
+
cause: new Error(`valkey ${name} did not answer within ${String(options.commandTimeoutMs)} ms`)
|
|
238
|
+
}))
|
|
239
|
+
}), Effect3.withSpan(`valkey ${name}`, {
|
|
240
|
+
kind: "client",
|
|
241
|
+
attributes: { ...options.attributes, "db.operation.name": name }
|
|
242
|
+
}));
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/state-valkey.ts
|
|
247
|
+
var DEFAULT_CONNECTION_TIMEOUT = Duration3.seconds(5);
|
|
248
|
+
var DEFAULT_COMMAND_TIMEOUT = Duration3.seconds(10);
|
|
249
|
+
function milliseconds(input, name) {
|
|
250
|
+
const ms = Duration3.toMillis(input);
|
|
251
|
+
if (!Number.isFinite(ms) || ms <= 0 || ms > 2 ** 31 - 1) {
|
|
252
|
+
throw new RangeError(`${name} must be a finite duration above 0 and at most 2 ** 31 - 1 ms`);
|
|
253
|
+
}
|
|
254
|
+
return Math.ceil(ms);
|
|
255
|
+
}
|
|
256
|
+
var refused2 = (reason) => new Redis2.RedisError({ cause: new Error(`SeatState valkey: ${reason}`) });
|
|
257
|
+
function isPermissionDenied(error) {
|
|
258
|
+
if (!(error instanceof Redis2.RedisError))
|
|
259
|
+
return false;
|
|
260
|
+
const cause = error.cause;
|
|
261
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
262
|
+
return message.startsWith("NOPERM");
|
|
263
|
+
}
|
|
264
|
+
var subscribe = () => Effect4.fail(refused2("subscribe is not supported here (a subscriber needs its own connection)"));
|
|
265
|
+
var loadClient = Effect4.tryPromise({
|
|
266
|
+
try: async () => (await import("bun")).RedisClient,
|
|
267
|
+
catch: () => refused2("needs Bun (Bun.RedisClient); this runtime does not have it")
|
|
268
|
+
});
|
|
269
|
+
var build = Effect4.fnUntraced(function* (options) {
|
|
270
|
+
const url = Redacted4.value(typeof options.url === "string" ? Redacted4.make(options.url) : options.url);
|
|
271
|
+
const fields = valkeyFields(url);
|
|
272
|
+
if (fields === undefined) {
|
|
273
|
+
return yield* refused2("the URL is not a redis, valkey, rediss or valkeys URL");
|
|
274
|
+
}
|
|
275
|
+
const connectionTimeout = options.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT;
|
|
276
|
+
const commandTimeout = options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT;
|
|
277
|
+
const connectMs = milliseconds(connectionTimeout, "connectionTimeout");
|
|
278
|
+
const commandMs = milliseconds(commandTimeout, "commandTimeout");
|
|
279
|
+
const maxRetries = options.maxRetries;
|
|
280
|
+
if (maxRetries !== undefined && !(Number.isInteger(maxRetries) && maxRetries >= 0)) {
|
|
281
|
+
throw new RangeError("maxRetries must be a non-negative integer");
|
|
282
|
+
}
|
|
283
|
+
const RedisClient = yield* loadClient;
|
|
284
|
+
const client = yield* Effect4.acquireRelease(Effect4.try({
|
|
285
|
+
try: () => new RedisClient(url, {
|
|
286
|
+
connectionTimeout: connectMs,
|
|
287
|
+
enableOfflineQueue: false,
|
|
288
|
+
...maxRetries === undefined ? {} : { maxRetries }
|
|
289
|
+
}),
|
|
290
|
+
catch: () => refused2("Bun.RedisClient refused the URL")
|
|
291
|
+
}), (opened) => Effect4.sync(() => opened.close()));
|
|
292
|
+
yield* Effect4.tryPromise({
|
|
293
|
+
try: () => client.connect(),
|
|
294
|
+
catch: (cause) => new Redis2.RedisError({ cause })
|
|
295
|
+
}).pipe(Effect4.timeoutOrElse({
|
|
296
|
+
duration: connectionTimeout,
|
|
297
|
+
orElse: () => Effect4.fail(refused2(`did not connect within ${String(connectMs)} ms`))
|
|
298
|
+
}));
|
|
299
|
+
const attributes = {
|
|
300
|
+
"db.system.name": "redis",
|
|
301
|
+
...fields.host === undefined ? {} : { "server.address": fields.host },
|
|
302
|
+
...fields.port === undefined ? {} : { "server.port": fields.port },
|
|
303
|
+
...fields.database === undefined ? {} : { "db.namespace": fields.database }
|
|
304
|
+
};
|
|
305
|
+
yield* keepConnected(client, { connectionTimeout, attributes });
|
|
306
|
+
return yield* Redis2.make({
|
|
307
|
+
send: instrumentedSend(client, { commandTimeout, commandTimeoutMs: commandMs, attributes }),
|
|
308
|
+
subscribe
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
var valkey = (options) => Layer2.effect(Redis2.Redis, Effect4.suspend(() => build(options)));
|
|
312
|
+
var VALKEY_URL_VARIABLE = "SEAT_VALKEY_URL";
|
|
313
|
+
var valkeyFromEnv = (options) => {
|
|
314
|
+
const { variable, ...rest } = options ?? {};
|
|
315
|
+
return Layer2.unwrap(Config2.Redacted(variable ?? VALKEY_URL_VARIABLE).pipe(Effect4.map((url) => valkey({ ...rest, url }))));
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// src/seat-state.ts
|
|
319
|
+
import { PgClient as PgClient2 } from "@effect/sql-pg";
|
|
320
|
+
var layer = (options) => Layer3.mergeAll(postgres(options.postgres), valkey(options.valkey));
|
|
321
|
+
var layerFromEnv = (options) => Layer3.mergeAll(postgresFromEnv(options?.postgres), valkeyFromEnv(options?.valkey));
|
|
322
|
+
export {
|
|
323
|
+
exports_seat_state as SeatState
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
//# debugId=5D1C2698CE7F927C64756E2164756E21
|
|
327
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/seat-state.ts", "../src/state-postgres.ts", "../src/state-dsn.ts", "../src/state-valkey.ts", "../src/state-valkey-connection.ts", "../src/state-valkey-send.ts", "../src/state-valkey-scrub.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * `SeatState`: a seat's Postgres and Valkey as Effect services, one layer each, in the trace of\n * the run that uses them.\n *\n * ⛔ THE LIBRARY HOLDS NO HOST. Every URL comes from the consumer (a value, or an environment\n * variable it names), as `Redacted`; there is no default address here to point a seat at the\n * wrong store, and nothing reads a credential from disk.\n * ★ THE SERVICES ARE EFFECT'S, NOT OURS: `SqlClient` (`effect/unstable/sql/SqlClient`) for\n * Postgres and `Redis` (`effect/unstable/persistence/Redis`) for Valkey, so a consumer writes\n * ordinary Effect SQL and Redis code and this package adds only how they are built.\n */\nimport * as Layer from 'effect/Layer';\nimport type * as Redis from 'effect/unstable/persistence/Redis';\nimport type * as SqlClient from 'effect/unstable/sql/SqlClient';\nimport type { SqlError } from 'effect/unstable/sql/SqlError';\nimport type { Config } from 'effect';\nimport type { PgClient } from '@effect/sql-pg';\nimport {\n type PostgresFromEnvOptions,\n type PostgresOptions,\n postgres,\n postgresFromEnv,\n} from './state-postgres.ts';\nimport {\n type ValkeyFromEnvOptions,\n type ValkeyOptions,\n valkey,\n valkeyFromEnv,\n} from './state-valkey.ts';\n\nexport * from './state-postgres.ts';\nexport * from './state-valkey.ts';\n// ★ RE-EXPORTED so a consumer under pnpm's strict resolution can name `PgClient` (for `.json`,\n// `.listen`, `.notify`) without also depending on `@effect/sql-pg` itself.\nexport { PgClient } from '@effect/sql-pg';\n\n/** Both stores, each built from an explicit URL. */\nexport const layer = (options: {\n readonly postgres: PostgresOptions;\n readonly valkey: ValkeyOptions;\n}): Layer.Layer<\n PgClient.PgClient | SqlClient.SqlClient | Redis.Redis,\n SqlError | Redis.RedisError\n> => Layer.mergeAll(postgres(options.postgres), valkey(options.valkey));\n\n/** Both stores, each URL read from its environment variable (`SEAT_POSTGRES_URL`, `SEAT_VALKEY_URL`). */\nexport const layerFromEnv = (options?: {\n readonly postgres?: PostgresFromEnvOptions | undefined;\n readonly valkey?: ValkeyFromEnvOptions | undefined;\n}): Layer.Layer<\n PgClient.PgClient | SqlClient.SqlClient | Redis.Redis,\n SqlError | Redis.RedisError | Config.ConfigError\n> => Layer.mergeAll(postgresFromEnv(options?.postgres), valkeyFromEnv(options?.valkey));\n",
|
|
6
|
+
"/**\n * Postgres as Effect's own `SqlClient`, through `@effect/sql-pg`.\n *\n * ★ THE SDK'S OWN LAYER, NOT A WRAPPER OF IT. `PgClient` opens the pool and creates one\n * `sql.execute` client span per statement carrying `db.system.name: postgresql`,\n * `db.namespace`, `server.address`, `server.port` and `db.query.text` (measured 2026-09-29\n * against CT100's Postgres), so state lands in the seat's trace with no `withSpan` of ours.\n * ⚠️ `db.query.text` is the statement with its `$1` placeholders: parameter VALUES are not in\n * the span (tests/state-postgres.test.ts asserts a canary value never leaves).\n * ⛔ WHAT THIS ADDS is the two things the driver leaves to the caller, both measured\n * 2026-09-29 and both in state-dsn.ts: a DSN that will not parse never reaches the driver (its\n * error carries the string, password included), and the URL's host, port, database and user are\n * passed as discrete fields so the spans name the database that was actually reached (from `url`\n * alone they say `localhost:5432`, database `postgres`).\n * ⛔ BUILDING THE LAYER RUNS `select 1`. The pool is lazy (a bad host or password otherwise fails\n * at the first query), so a seat that starts against a dead or refusing Postgres is told at\n * startup, as `SqlError`, within `connectTimeout`.\n * ⚠️ PORTABLE. `@effect/sql-pg` rc.115 speaks the wire protocol itself over `node:net`, with no\n * driver package, so this half runs under Bun and Node alike (the Valkey half needs Bun).\n */\nimport { PgClient } from '@effect/sql-pg';\nimport * as Config from 'effect/Config';\nimport * as Duration from 'effect/Duration';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\nimport * as Redacted from 'effect/Redacted';\nimport type * as SqlClient from 'effect/unstable/sql/SqlClient';\nimport { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError';\nimport { postgresFields } from './state-dsn.ts';\n\nexport type PostgresOptions = {\n /**\n * `postgres://<user>:<password>@<host>:<port>/<database>` (or `postgresql://`), the user and\n * password percent-encoded. Held `Redacted`; never in a span, log or error. Required: this\n * package holds no host. `sslmode=require|verify-ca|verify-full|disable` is read; the driver\n * refuses `prefer` and `allow`.\n */\n readonly url: string | Redacted.Redacted<string>;\n /** Pool ceiling; the driver's default when absent. */\n readonly maxConnections?: number | undefined;\n /** How long to wait for a connection, and for the startup `select 1`. Driver default 5 s. */\n readonly connectTimeout?: Duration.Input | undefined;\n /** Shown in `pg_stat_activity`; the driver's default when absent. */\n readonly applicationName?: string | undefined;\n};\n\n/** The environment variable `postgresFromEnv` reads unless told another. Not a host. */\nexport const POSTGRES_URL_VARIABLE = 'SEAT_POSTGRES_URL';\n\nconst DEFAULT_CONNECT_TIMEOUT: Duration.Duration = Duration.seconds(5);\n\n/** A failure that names the reason and never the DSN. */\nconst refused = (message: string): SqlError =>\n new SqlError({\n reason: new ConnectionError({\n cause: new Error(message),\n message: `SeatState postgres: ${message}`,\n operation: 'connect',\n }),\n });\n\nconst acquire = Effect.fnUntraced(function* (options: PostgresOptions) {\n const dsn = Redacted.value(\n typeof options.url === 'string' ? Redacted.make(options.url) : options.url,\n );\n const fields = postgresFields(dsn);\n if (fields === undefined) return yield* refused('the URL is not a postgres:// URL');\n const connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT;\n const client = yield* PgClient.make({\n url: Redacted.make(dsn),\n ...fields,\n maxConnections: options.maxConnections,\n connectTimeout,\n applicationName: options.applicationName,\n });\n yield* client`select 1`.pipe(\n Effect.timeoutOrElse({\n duration: connectTimeout,\n orElse: () =>\n Effect.fail(\n refused(`did not answer within ${String(Duration.toMillis(connectTimeout))} ms`),\n ),\n }),\n );\n return client;\n});\n\n/**\n * `SqlClient` and `PgClient` against one Postgres. The pool closes with the layer's scope.\n */\nexport const postgres = (\n options: PostgresOptions,\n): Layer.Layer<PgClient.PgClient | SqlClient.SqlClient, SqlError> =>\n PgClient.layerFrom(Effect.suspend(() => acquire(options)));\n\nexport type PostgresFromEnvOptions = Omit<PostgresOptions, 'url'> & {\n /** The variable holding the URL. Default `SEAT_POSTGRES_URL`. */\n readonly variable?: string | undefined;\n};\n\n/**\n * `postgres`, its URL read from an environment variable as a `Redacted` secret. A missing\n * variable fails as `ConfigError`, naming the variable and nothing else.\n */\nexport const postgresFromEnv = (\n options?: PostgresFromEnvOptions,\n): Layer.Layer<PgClient.PgClient | SqlClient.SqlClient, SqlError | Config.ConfigError> => {\n const { variable, ...rest } = options ?? {};\n return Layer.unwrap(\n Config.Redacted(variable ?? POSTGRES_URL_VARIABLE).pipe(\n Effect.map((url) => postgres({ ...rest, url })),\n ),\n );\n};\n",
|
|
7
|
+
"/**\n * Reading a connection string without ever printing it.\n *\n * ⛔ A DSN CARRIES A PASSWORD, so nothing here returns, throws or formats the string itself: a\n * caller gets back the few non-secret fields (host, port, database, user name) or `undefined`.\n * 🔴 WHY THIS EXISTS AT ALL. Measured 2026-09-29 (`@effect/sql-pg` rc.115, Bun 1.4.0): a DSN that\n * `new URL` cannot parse fails as `SqlError` -> `ConnectionError` whose `cause` is the URL\n * `TypeError`, and that error's text carries the WHOLE string, password included. Three of the\n * six bad DSNs tried leaked it through `JSON.stringify` and `Bun.inspect` alike. Parsing here\n * first, with the same `new URL`, means a string that would leak never reaches the driver:\n * what the driver still rejects (a wrong scheme, an `sslmode` it lacks) echoes only that one\n * token, not the string.\n * 🔴 THE SECOND REASON: `@effect/sql-pg` labels every query span from its discrete config fields\n * and IGNORES the URL for that. A client built from `url` alone exports\n * `server.address: localhost`, `server.port: 5432` and `db.namespace: postgres` for EVERY\n * query whatever it is connected to (measured 2026-09-29 against CT100's Postgres through a\n * tunnel), so a trace would point at the wrong database. `postgresFields` hands the driver the\n * same host, port, database and user the URL names, decoded the way the driver decodes them.\n */\n\n/** The non-secret fields a Postgres URL names; a field the URL leaves out is absent. */\nexport type PostgresFields = {\n readonly host?: string | undefined;\n readonly port?: number | undefined;\n readonly database?: string | undefined;\n readonly username?: string | undefined;\n};\n\n/** The non-secret fields a Valkey URL names; `port` defaults to 6379 for a TCP scheme. */\nexport type ValkeyFields = {\n readonly host?: string | undefined;\n readonly port?: number | undefined;\n /** The logical database index (`redis://host/1`), when there is one. */\n readonly database?: string | undefined;\n};\n\n/**\n * Query keys that OVERRIDE the authority in `@effect/sql-pg`'s own parser (rc.115 `parseUrl`).\n * ⚠️ When a URL carries one, the fields below would be a second, conflicting answer (explicit\n * config wins over the URL), so none are derived and the driver's reading stands.\n */\nconst OVERRIDING_KEYS: ReadonlySet<string> = new Set(['host', 'port', 'user', 'dbname']);\n\n/** `decodeURIComponent` that reports failure as `undefined` instead of throwing its message. */\nfunction decode(value: string): string | undefined {\n try {\n return decodeURIComponent(value);\n } catch {\n return undefined;\n }\n}\n\n/** `new URL` that reports failure as `undefined`: its `TypeError` carries the string. */\nfunction parse(raw: string): URL | undefined {\n try {\n return new URL(raw);\n } catch {\n return undefined;\n }\n}\n\n/**\n * `undefined` when `dsn` is not a URL at all (the one case the driver would leak on); otherwise\n * the fields it names, or none when the URL is one the driver should read alone.\n */\nexport function postgresFields(dsn: string): PostgresFields | undefined {\n const url = parse(dsn);\n if (url === undefined) return undefined;\n for (const key of url.searchParams.keys()) if (OVERRIDING_KEYS.has(key)) return {};\n const hostname =\n url.hostname.startsWith('[') && url.hostname.endsWith(']')\n ? url.hostname.slice(1, -1)\n : decode(url.hostname);\n const database = decode(url.pathname.replace(/^\\//, ''));\n const username = decode(url.username);\n const port = url.port === '' ? undefined : Number(url.port);\n // ⚠️ A piece that will not decode or parse is left to the driver, which fails with its own\n // message (it names a port or a component, never the string).\n if (hostname === undefined || database === undefined || username === undefined) return {};\n if (port !== undefined && !(Number.isInteger(port) && port >= 1 && port <= 65535)) return {};\n return {\n host: hostname === '' ? undefined : hostname,\n port,\n database: database === '' ? undefined : database,\n username: username === '' ? undefined : username,\n };\n}\n\n/** The URL schemes `Bun.RedisClient` accepts (its own error names exactly these seven). */\nconst VALKEY_SCHEMES: ReadonlySet<string> = new Set([\n 'redis:',\n 'valkey:',\n 'rediss:',\n 'valkeys:',\n 'redis+tls:',\n 'redis+unix:',\n 'redis+tls+unix:',\n]);\n\n/** `undefined` when `raw` is not a URL of a scheme `Bun.RedisClient` takes. */\nexport function valkeyFields(raw: string): ValkeyFields | undefined {\n const url = parse(raw);\n if (url === undefined || !VALKEY_SCHEMES.has(url.protocol)) return undefined;\n if (url.protocol.endsWith('+unix:')) return {};\n const host = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;\n const database = url.pathname.replace(/^\\//, '');\n return {\n host: host === '' ? undefined : host,\n port: url.port === '' ? 6379 : Number(url.port),\n database: database === '' ? undefined : database,\n };\n}\n",
|
|
8
|
+
"/**\n * Valkey as Effect's own `Redis` service, over `Bun.RedisClient`.\n *\n * ★ `Redis.make` OVER THE BUILT-IN CLIENT, NOT `@effect/platform-bun`'s `BunRedis`. Rc.115 ships\n * `BunRedis.layer`, and it would do; but a dependency on `@effect/platform-bun` drags\n * `@effect/platform-node-shared ^rc.115` in behind it, which resolves to rc.118 on a fresh\n * install and kills the process at import, and only a ROOT `overrides` fixes that (docs/pairing.md,\n * measured 2026-09-29). A library cannot ship one. So this is the same ~30 lines (`send` over\n * `client.send`), with the two things `BunRedis` lacks: a connection that must succeed before the\n * layer is built, and a deadline on every command.\n * ⛔ THE URL CARRIES THE ACL USER (`redis://seat:<password>@host:port`, percent-encoded), measured\n * 2026-09-29 against a scratch Valkey with `user default off`: an unauthenticated client is\n * refused `NOAUTH`; the seat user reads and writes its own prefix; a write outside it fails\n * `NOPERM No permissions to access a key`, and a command outside its categories fails\n * `NOPERM User seat has no permissions to run the 'flushall' command`. All of them arrive as\n * `RedisError`, and `isPermissionDenied` names the two `NOPERM` ones.\n * ⛔ NO `subscribe`. `Redis.subscribe` here fails with `RedisError`: a Valkey subscriber needs a\n * connection of its own that this layer does not open. (`BunRedis` opens one, with no reconnect;\n * a caller that needs pub/sub can use it beside this layer.)\n * ⚠️ BUN ONLY, AND SAID SO AT THE FIRST USE. `Bun.RedisClient` is loaded with `import('bun')`, so\n * importing this module under Node still works (the Postgres half is portable); BUILDING the\n * Valkey layer there fails with a `RedisError` naming the reason.\n * 🔴 `connectionTimeout` DOES NOT BOUND DNS. Measured 2026-09-29: with `connectionTimeout: 700`, a\n * host name that does not resolve failed after 31 s. `connect()` therefore also runs under an\n * Effect timeout of the same length.\n * 🔴 BUN'S OWN RECONNECT ENDS, AND THE CLIENT THEN STAYS DEAD (about 31 s of outage here): the layer\n * reconnects it itself, off `onclose` (state-valkey-connection.ts holds the measurement).\n */\nimport * as Config from 'effect/Config';\nimport * as Duration from 'effect/Duration';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\nimport * as Redacted from 'effect/Redacted';\nimport * as Redis from 'effect/unstable/persistence/Redis';\nimport { valkeyFields } from './state-dsn.ts';\nimport { keepConnected } from './state-valkey-connection.ts';\nimport { instrumentedSend } from './state-valkey-send.ts';\n\nexport type ValkeyOptions = {\n /**\n * `redis://<user>:<password>@<host>:<port>[/<db>]` (or `valkey://`, `rediss://` for TLS), the\n * user and password percent-encoded. Held `Redacted`; never in a span, log or error. Required:\n * this package holds no host.\n */\n readonly url: string | Redacted.Redacted<string>;\n /**\n * How long to wait to connect and authenticate when the layer is built, and for each reconnect\n * attempt after Bun gives up. Default 5 s (Bun's own is 10 s). ⚠️ A finite positive duration of at\n * most 2 ** 31 - 1 ms, or a `RangeError` defect.\n */\n readonly connectionTimeout?: Duration.Input | undefined;\n /** How long one command may take. Default 10 s; same limits as `connectionTimeout`. */\n readonly commandTimeout?: Duration.Input | undefined;\n /**\n * How many times Bun retries a lost connection before it gives up (its default is 20, about 30\n * s of outage). After that THE LAYER reconnects until the server is back, so this only sets\n * when its own loop takes over. A non-negative integer, or a `RangeError` defect.\n */\n readonly maxRetries?: number | undefined;\n};\n\nexport const DEFAULT_CONNECTION_TIMEOUT: Duration.Duration = Duration.seconds(5);\nexport const DEFAULT_COMMAND_TIMEOUT: Duration.Duration = Duration.seconds(10);\n\n/** A duration as whole milliseconds Bun and `setTimeout` accept, or a `RangeError`. */\nfunction milliseconds(input: Duration.Input, name: string): number {\n const ms = Duration.toMillis(input);\n if (!Number.isFinite(ms) || ms <= 0 || ms > 2 ** 31 - 1) {\n throw new RangeError(`${name} must be a finite duration above 0 and at most 2 ** 31 - 1 ms`);\n }\n return Math.ceil(ms);\n}\n\n/** A failure that names the reason and never the URL. */\nconst refused = (reason: string): Redis.RedisError =>\n new Redis.RedisError({ cause: new Error(`SeatState valkey: ${reason}`) });\n\n/**\n * Whether `error` is the server's `NOPERM`: a key outside the user's prefix, or a command outside\n * its categories. ⚠️ It reads the message, because Bun reports both as one `code`\n * (`ERR_REDIS_SERVER_ERROR`) and the server's own text is the only thing that tells them apart\n * from a real server fault.\n */\nexport function isPermissionDenied(error: unknown): boolean {\n if (!(error instanceof Redis.RedisError)) return false;\n const cause: unknown = error.cause;\n const message = cause instanceof Error ? cause.message : String(cause);\n return message.startsWith('NOPERM');\n}\n\n/** Subscribing is not offered; see the header. */\nconst subscribe = (): Effect.Effect<never, Redis.RedisError> =>\n Effect.fail(refused('subscribe is not supported here (a subscriber needs its own connection)'));\n\n/** Loads `Bun.RedisClient`, or fails naming why it is not there (Node, workerd). */\nconst loadClient = Effect.tryPromise({\n try: async () => (await import('bun')).RedisClient,\n catch: () => refused('needs Bun (Bun.RedisClient); this runtime does not have it'),\n});\n\nconst build = Effect.fnUntraced(function* (options: ValkeyOptions) {\n const url = Redacted.value(\n typeof options.url === 'string' ? Redacted.make(options.url) : options.url,\n );\n const fields = valkeyFields(url);\n if (fields === undefined) {\n return yield* refused('the URL is not a redis, valkey, rediss or valkeys URL');\n }\n const connectionTimeout = options.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT;\n const commandTimeout = options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT;\n const connectMs = milliseconds(connectionTimeout, 'connectionTimeout');\n const commandMs = milliseconds(commandTimeout, 'commandTimeout');\n const maxRetries = options.maxRetries;\n if (maxRetries !== undefined && !(Number.isInteger(maxRetries) && maxRetries >= 0)) {\n throw new RangeError('maxRetries must be a non-negative integer');\n }\n const RedisClient = yield* loadClient;\n // ⛔ OFFLINE QUEUE OFF: a command sent while the connection is down fails at once as\n // `RedisError` (a seat retries in Effect, where an attempt is a span) instead of waiting for\n // a reconnect that may not come. Measured 2026-09-29: after a server restart the same client\n // answered again within 1.5 s; past Bun's retry budget the layer reconnects behind the\n // caller's retry (state-valkey-connection.ts). It is also why the layer connects first: an\n // unconnected client with the queue off refuses every command.\n const client = yield* Effect.acquireRelease(\n Effect.try({\n try: () =>\n new RedisClient(url, {\n connectionTimeout: connectMs,\n enableOfflineQueue: false,\n ...(maxRetries === undefined ? {} : { maxRetries }),\n }),\n catch: () => refused('Bun.RedisClient refused the URL'),\n }),\n (opened) => Effect.sync(() => opened.close()),\n );\n yield* Effect.tryPromise({\n try: () => client.connect(),\n catch: (cause) => new Redis.RedisError({ cause }),\n }).pipe(\n Effect.timeoutOrElse({\n duration: connectionTimeout,\n orElse: () => Effect.fail(refused(`did not connect within ${String(connectMs)} ms`)),\n }),\n );\n // ⚠️ Address and namespace only: the URL's user and password never reach an attribute.\n const attributes = {\n 'db.system.name': 'redis',\n ...(fields.host === undefined ? {} : { 'server.address': fields.host }),\n ...(fields.port === undefined ? {} : { 'server.port': fields.port }),\n ...(fields.database === undefined ? {} : { 'db.namespace': fields.database }),\n };\n // ⛔ AFTER the first connect, so a wrong URL or password fails the build instead of being retried.\n yield* keepConnected(client, { connectionTimeout, attributes });\n return yield* Redis.make({\n send: instrumentedSend(client, { commandTimeout, commandTimeoutMs: commandMs, attributes }),\n subscribe,\n });\n});\n\n/**\n * The `Redis` service against one Valkey. ⛔ BUILDING IT CONNECTS AND AUTHENTICATES, so a wrong\n * URL, user or password fails at startup as `RedisError`, not at the first command (Bun reports a\n * refused password as `Connection closed`, the same text as a server that is down).\n * The client is closed when the layer's scope closes.\n */\nexport const valkey = (options: ValkeyOptions): Layer.Layer<Redis.Redis, Redis.RedisError> =>\n Layer.effect(\n Redis.Redis,\n Effect.suspend(() => build(options)),\n );\n\n/** The environment variable `valkeyFromEnv` reads unless told another. Not a host. */\nexport const VALKEY_URL_VARIABLE = 'SEAT_VALKEY_URL';\n\nexport type ValkeyFromEnvOptions = Omit<ValkeyOptions, 'url'> & {\n /** The variable holding the URL. Default `SEAT_VALKEY_URL`. */\n readonly variable?: string | undefined;\n};\n\n/**\n * `valkey`, its URL read from an environment variable as a `Redacted` secret. A missing variable\n * fails as `ConfigError`, naming the variable and nothing else.\n */\nexport const valkeyFromEnv = (\n options?: ValkeyFromEnvOptions,\n): Layer.Layer<Redis.Redis, Redis.RedisError | Config.ConfigError> => {\n const { variable, ...rest } = options ?? {};\n return Layer.unwrap(\n Config.Redacted(variable ?? VALKEY_URL_VARIABLE).pipe(\n Effect.map((url) => valkey({ ...rest, url })),\n ),\n );\n};\n",
|
|
9
|
+
"/**\n * Keeping one `Bun.RedisClient` connected for as long as its layer lives.\n *\n * 🔴 BUN GIVES UP, AND A CLIENT THAT HAS GIVEN UP STAYS DEAD. Measured 2026-09-29 (Bun 1.4.0, a raw\n * client with `enableOfflineQueue: false` and the default `maxRetries` of 20, the server killed\n * for 60 s, then restarted): Bun retried on its own for 31 s (commands failed `Connection is\n * closed and offline queue is disabled`), then GAVE UP: `onclose` ran once and every command\n * after failed `Connection has failed`, still so 10 s after the server was back. Only calling\n * `connect()` again revived it (it resolved at once and `PING` answered). The budget is a sum of\n * backoffs, so it is not one number: the review that found this measured recovery after 30 s and\n * none after 45 s. A seat whose Valkey restarts for a minute (a container restart, CT100 booting)\n * would keep a dead client until the process restarted, and no retry in Effect could help, since\n * every retry hit the same dead client.\n * ★ SO THE LAYER RECONNECTS ITSELF, off `onclose`: single-flight, each attempt bounded by\n * `connectionTimeout` (which does not bound DNS; see state-valkey.ts), backing off 250 ms up to\n * 5 s between attempts, one `valkey reconnect` span per attempt and a warning and an info line\n * around the outage. It stops when the layer's scope closes.\n * ⚠️ `onclose` IS A HINT AND `connected` IS THE FACT. Measured on the same client: `onclose` also\n * runs for every `connect()` that fails (each rejected after ~155 ms with `Connection closed`)\n * and for the client's own `close()`, so a reconnect that trusted it would start itself. The\n * loop therefore runs only while `connected` is false, and a wake-up that arrives after a\n * successful reconnect finds nothing to do.\n * ⚠️ COMMANDS STILL FAIL AT ONCE WHILE IT IS DOWN (the offline queue is off on purpose): the\n * caller's retry is what spans an outage, and from now on the retry is answered once this loop\n * has reconnected, within the pause plus one attempt of the server coming back.\n */\nimport type { RedisClient } from 'bun';\nimport * as Duration from 'effect/Duration';\nimport * as Effect from 'effect/Effect';\nimport * as Queue from 'effect/Queue';\nimport type * as Scope from 'effect/Scope';\n\n/** What this file needs of `Bun.RedisClient`. */\nexport type Reconnectable = Pick<RedisClient, 'connected' | 'connect' | 'onclose'>;\n\nexport type KeepOptions = {\n /** How long one reconnect attempt may take before it is abandoned and retried. */\n readonly connectionTimeout: Duration.Input;\n /** Span attributes every attempt carries: the server address, no secrets. */\n readonly attributes: Readonly<Record<string, unknown>>;\n /** The pause after the first failed attempt, doubled up to `longestPause`. Default 250 ms. */\n readonly firstPause?: Duration.Input | undefined;\n /** The longest pause between attempts. Default 5 s. */\n readonly longestPause?: Duration.Input | undefined;\n};\n\nconst DEFAULT_FIRST_PAUSE_MS = 250;\nconst DEFAULT_LONGEST_PAUSE_MS = 5000;\n\n/**\n * Reconnect `client` whenever Bun gives up on it, for the life of the enclosing scope. Call it\n * AFTER the first `connect()` succeeded, so a wrong URL or password still fails the layer's build\n * instead of being retried forever.\n */\nexport const keepConnected: (\n client: Reconnectable,\n options: KeepOptions,\n) => Effect.Effect<void, never, Scope.Scope> = Effect.fnUntraced(function* (\n client: Reconnectable,\n options: KeepOptions,\n) {\n const wake = yield* Queue.sliding<void>(1);\n const firstPause = Duration.toMillis(options.firstPause ?? DEFAULT_FIRST_PAUSE_MS);\n const longestPause = Duration.toMillis(options.longestPause ?? DEFAULT_LONGEST_PAUSE_MS);\n // ⛔ ONE `connect()` IN FLIGHT AT A TIME. An attempt that times out is abandoned, but Bun's own\n // promise is still pending (a name that does not resolve took 31 s), and a second `connect()`\n // on top of it is not something Bun documents. The next attempt awaits the same promise.\n let connecting: Promise<void> | undefined;\n const connectOnce = (): Promise<void> => {\n connecting ??= client.connect().finally(() => {\n connecting = undefined;\n });\n return connecting;\n };\n const attempt = Effect.tryPromise({ try: connectOnce, catch: (cause) => cause }).pipe(\n Effect.timeoutOrElse({\n duration: options.connectionTimeout,\n orElse: () => Effect.fail(new Error('a reconnect attempt did not finish in time')),\n }),\n Effect.withSpan('valkey reconnect', { kind: 'client', attributes: options.attributes }),\n Effect.as(true),\n Effect.orElseSucceed(() => false),\n );\n const reconnect = Effect.gen(function* () {\n if (client.connected) return;\n yield* Effect.logWarning('SeatState valkey: the connection is down, reconnecting');\n let pause = firstPause;\n let attempts = 0;\n while (!client.connected) {\n attempts += 1;\n yield* attempt;\n // ⚠️ `connected` decides, not the attempt's own answer: an attempt that resolved without the\n // client being connected must pause like a failed one, not spin.\n if (client.connected) break;\n yield* Effect.sleep(pause);\n pause = Math.min(pause * 2, longestPause);\n }\n yield* Effect.logInfo(`SeatState valkey: reconnected after ${String(attempts)} attempt(s)`);\n });\n client.onclose = () => {\n Queue.offerUnsafe(wake, undefined);\n };\n // ⛔ A NO-OP, NEVER `null`: measured 2026-09-29 (Bun 1.4.0), `client.onclose = null` (or\n // `undefined`) is accepted, and then `close()` calls the null and throws `TypeError: ... is not\n // a function` (worded with whatever call site is on the stack, `c.close()` or a fiber's\n // `this[args]()`). bun-types types `onclose` as `... | null`; Bun does not honour it.\n yield* Effect.addFinalizer(() =>\n Effect.sync(() => {\n client.onclose = () => {};\n }),\n );\n yield* Effect.forkScoped(Effect.forever(Queue.take(wake).pipe(Effect.andThen(reconnect))));\n});\n",
|
|
10
|
+
"/**\n * One Valkey command as a span, with a deadline, failing as `RedisError`.\n *\n * ⛔ A SPAN NAMES THE COMMAND, NEVER ITS ARGUMENTS. Keys are seat data and values are whatever the\n * seat stored; `AUTH` and `HELLO ... AUTH` carry a password. Only the upper-cased command name\n * (`SET`, `GET`) is recorded, so the span is low-cardinality and holds nothing to redact.\n * 🔴 NOR IS THE ERROR TEXT: the server quotes the arguments of a call it refuses (`unknown command\n * 'X', with args beginning with: '<key>' '<value>'`), and a failed span exports its error, so\n * what a caller receives is `scrubbedError`, never Bun's own (state-valkey-scrub.ts).\n * 🔴 THE DEADLINE IS NOT OPTIONAL. Measured 2026-09-29 (Bun 1.4.0): a `Bun.RedisClient` on its\n * defaults that has lost its server QUEUES every command and never answers it until the server\n * is back (a `send` sat unresolved past 8 s against a dead port; after a restart the queued\n * command completed ~5 s later). With the offline queue off (`state-valkey.ts` sets it) a dropped\n * connection fails at once, but a peer that holds the socket open and says nothing would still\n * wedge a seat forever, so every command carries `commandTimeout` too.\n */\nimport * as Effect from 'effect/Effect';\nimport * as Redis from 'effect/unstable/persistence/Redis';\nimport type * as Duration from 'effect/Duration';\nimport { scrubbedError } from './state-valkey-scrub.ts';\n\n/** What this file needs of a client: `Bun.RedisClient` has this exact `send`. */\nexport type ValkeyCall = {\n readonly send: (command: string, args: Array<string>) => Promise<unknown>;\n};\n\n/** `send` as `Redis.make` wants it. */\nexport type Send = <A = unknown>(\n command: string,\n ...args: ReadonlyArray<string>\n) => Effect.Effect<A, Redis.RedisError>;\n\nexport type SendOptions = {\n /** How long one command may take before it fails as `RedisError`. */\n readonly commandTimeout: Duration.Input;\n /** The command deadline in milliseconds, for the message only. */\n readonly commandTimeoutMs: number;\n /** Span attributes every command carries: `db.system.name`, the server address, no secrets. */\n readonly attributes: Readonly<Record<string, unknown>>;\n};\n\nexport function instrumentedSend(client: ValkeyCall, options: SendOptions): Send {\n return <A = unknown>(command: string, ...args: ReadonlyArray<string>) => {\n const name = command.toUpperCase();\n return Effect.tryPromise({\n // ⚠️ Bun's `send` types its argument list as a mutable array; ours is readonly.\n try: () => client.send(command, [...args]) as Promise<A>,\n catch: (cause) => new Redis.RedisError({ cause: scrubbedError(cause, command) }),\n }).pipe(\n Effect.timeoutOrElse({\n duration: options.commandTimeout,\n orElse: () =>\n Effect.fail(\n new Redis.RedisError({\n cause: new Error(\n `valkey ${name} did not answer within ${String(options.commandTimeoutMs)} ms`,\n ),\n }),\n ),\n }),\n Effect.withSpan(`valkey ${name}`, {\n kind: 'client',\n attributes: { ...options.attributes, 'db.operation.name': name },\n }),\n );\n };\n}\n",
|
|
11
|
+
"/**\n * What a Valkey error may say once it has left the layer.\n *\n * 🔴 THE SERVER'S ERROR TEXT ECHOES THE ARGUMENTS OF THE CALL THAT FAILED. Measured 2026-09-29\n * (Valkey 9.1.1, through `Bun.RedisClient`, with canary values):\n * ERR unknown command 'JSON.SET', with args beginning with: 'seat:key-…' '$' '{\"secret\":…}'\n * ERR unknown subcommand 'seat:key-…'. Try OBJECT HELP.\n * Keys are seat data and values are whatever the seat stored, and Effect's `OtlpTracer` exports\n * every FAILED span's error as `exception.message` and `exception.stacktrace` (with the whole\n * `cause` chain, `includeCauseInStack: true`), so those two errors put a key and a value into\n * the exported trace: the OTLP payload a Victoria service would receive held both (a wire test\n * in tests/state-error-text.test.ts asserts it now does not). A `Logger` that prints a failed\n * call does the same.\n * ★ SO THE ERROR THAT LEAVES THE LAYER IS A NEW `Error` WITH THE ARGUMENTS CUT OUT, and the\n * original is not chained (a `cause` would carry its text straight back into the stack). Kept:\n * Bun's `code` and `name`, and the server's own words up to the first quote that opens\n * something other than the command that was sent. That keeps `NOPERM User seat has no\n * permissions to run the 'flushall' command` whole (so `isPermissionDenied` and the wording are\n * unchanged) and cuts `unknown command 'JSON.SET', with args beginning with: ` at the first\n * argument, even when an argument holds a quote itself.\n * ⚠️ WHAT THIS DOES NOT COVER, said plainly: a server text that echoes an argument WITHOUT quotes,\n * and the text a Lua script raises itself (`error(...)` comes back as `ERR user_script:1: <text>`;\n * measured). Bun's own errors (`Connection closed`, a timeout) quote nothing.\n */\n\n/** What replaces the arguments. */\nexport const OMITTED = '[arguments omitted]';\n\n/**\n * `message` up to the first quoted thing that is not `command` (or one of its subcommands, which\n * the server writes `command|sub`), with `OMITTED` where the rest was.\n */\nexport function cutArguments(message: string, command: string): string {\n const name = command.toLowerCase();\n let at = message.indexOf(\"'\");\n while (at !== -1) {\n const close = message.indexOf(\"'\", at + 1);\n const quoted = close === -1 ? undefined : message.slice(at + 1, close).toLowerCase();\n if (quoted === undefined || !(quoted === name || quoted.startsWith(`${name}|`))) {\n return `${message.slice(0, at)}${OMITTED}`;\n }\n at = message.indexOf(\"'\", close + 1);\n }\n return message;\n}\n\n/**\n * The client's rejection as an `Error` that is safe to put in a span, a log or a `RedisError`:\n * a fresh one, keeping Bun's `name` and `code`, with the arguments cut out of the text.\n */\nexport function scrubbedError(cause: unknown, command: string): Error {\n const original = cause instanceof Error ? cause : undefined;\n const scrubbed = new Error(cutArguments(original?.message ?? String(cause), command));\n if (original !== undefined) {\n scrubbed.name = original.name;\n const code: unknown = (original as { readonly code?: unknown }).code;\n if (typeof code === 'string') Object.assign(scrubbed, { code });\n }\n return scrubbed;\n}\n"
|
|
12
|
+
],
|
|
13
|
+
"mappings": "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",
|
|
14
|
+
"debugId": "5D1C2698CE7F927C64756E2164756E21",
|
|
15
|
+
"names": []
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,OAAO,EAAE,MAAgB,CAAC"}
|
package/docs/mcp.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# `mcpToolkit`: behaviour, limits and measurements
|
|
2
|
+
|
|
3
|
+
The detail behind the [README](../README.md#mcptoolkit)'s section, kept here so the README
|
|
4
|
+
stays under its 200-line cap.
|
|
5
|
+
|
|
6
|
+
The official MCP SDK `Client` over Streamable HTTP; each MCP tool is a `Tool.dynamic` carrying
|
|
7
|
+
the server's own JSON Schema. It needs a `Scope`: the connection closes with it.
|
|
8
|
+
|
|
9
|
+
- **A tool failure goes back to the model**, not out of the run: an `isError` result, a JSON-RPC
|
|
10
|
+
error (bad arguments, unknown tool) and a call that never completed are all the tool's result.
|
|
11
|
+
Connecting and listing fail with `McpToolkitError` (`_tag`, `operation`, `server`), whose message
|
|
12
|
+
holds the origin and path only, and whose `cause` is a scrubbed copy: never the headers or the
|
|
13
|
+
query string (under Bun a refused fetch's own `path` field is the full URL; tests/printed.ts).
|
|
14
|
+
A query value or header value the server **echoes on its own** is redacted too (6 characters and
|
|
15
|
+
up); one the server transforms (hashed, base64) or one shorter than that is not. That covers
|
|
16
|
+
the text of a failed tool call the model reads, an `isError` result included; a **successful**
|
|
17
|
+
result is the tool's payload and is delivered verbatim.
|
|
18
|
+
- **Results are strings.** Text blocks verbatim; an image, audio or blob becomes a one-line marker,
|
|
19
|
+
never its bytes; an empty result reads `(no content)`.
|
|
20
|
+
- ⚠️ The tool list is a **snapshot** at connect time. Names are passed through unchanged: an
|
|
21
|
+
OpenAI-shaped API takes `[A-Za-z0-9_-]{1,64}`, so a server that names a tool with a dot is not
|
|
22
|
+
renamed here (not measured through LiteLLM's MCP gateway).
|
|
23
|
+
- 🔴 **compat rc.115 cannot decode a tool call for a `Tool.dynamic` alone** (measured 2026-09-29:
|
|
24
|
+
`UnsupportedSchemaError: Root JSON Schema must have type "object"`; rc.118 fixed it). The
|
|
25
|
+
workaround in `src/mcp-tool.ts` costs two things: an argument the server's schema **does not
|
|
26
|
+
declare is dropped**, and a tool that declares no properties takes no arguments. (An explicit
|
|
27
|
+
`null` on an optional argument arrives as `null`: tests/mcp-arguments.test.ts, end to end.)
|
|
28
|
+
- **`connectTimeoutMs`** (default 15 000; a finite number above 0 and at most `2 ** 31 - 1`, else a
|
|
29
|
+
`RangeError` defect) bounds the whole handshake (`initialize` and `notifications/initialized`) and
|
|
30
|
+
each `tools/list` page read at startup: per request, not a total. `listResources`, `readResource`
|
|
31
|
+
and tool calls keep the SDK's 60 s per request. All of it is interruptible.
|
|
32
|
+
- **A failed `mcpToolkit` leaves nothing behind**, wherever it fails: the handshake, the
|
|
33
|
+
`tools/list` (a JSON-RPC error, a page held past `connectTimeoutMs`) or a caller's interruption.
|
|
34
|
+
The client's finalizer is registered in your scope only once the whole toolkit is built, and each
|
|
35
|
+
step closes the client if it fails, so `Effect.retry` around `mcpToolkit` does not pile up
|
|
36
|
+
clients, sessions or sockets (tests/mcp-startup.test.ts, tests/mcp-list-failure.test.ts: the
|
|
37
|
+
scope stays `Empty` and the server holds no open stream or listing).
|
|
38
|
+
- `listResources` is empty when the server does not advertise resources; `readResource` fails
|
|
39
|
+
with `McpToolkitError` for an unknown URI. A workerd deployment is untested (the SDK's default
|
|
40
|
+
validator is `ajv`, which needs `new Function`).
|
package/docs/pairing.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# The measured pairing
|
|
2
|
+
|
|
3
|
+
What was measured about the versions `@homeflare/seat-runtime` pins, and why they are pinned
|
|
4
|
+
exactly. Moved out of the [README](../README.md), which is at its line cap; nothing here was
|
|
5
|
+
shortened.
|
|
6
|
+
|
|
7
|
+
Measured 2026-09-29 (a scratch install, then this package's tests and smoke):
|
|
8
|
+
|
|
9
|
+
- ✅ `effect` rc.115 with `@effect/ai-openai-compat` rc.115: clean install, `tsc` 7.0.2 exit 0,
|
|
10
|
+
and at runtime chat, a tool round, embeddings, three OTLP signals and `traceparent`.
|
|
11
|
+
- ⚠️ With `skipLibCheck: false`, compat's **own** `.d.ts` has 26 `TS2411` errors. Upstream's,
|
|
12
|
+
not ours; keep `skipLibCheck: true`. `scripts/smoke.ts` allows exactly those and nothing else.
|
|
13
|
+
- ⚠️ compat beta.107 beside effect rc.115 also installed and passed the same small surface
|
|
14
|
+
(the scout's `pairBeta`). Nothing wider was tried, so the rule stays **same exact rc**.
|
|
15
|
+
- 🔴 **rc.118 drops the `unstable/` prefix**: `effect/unstable/ai` becomes `effect/ai`. rc.116
|
|
16
|
+
and rc.117 keep it. The estate is pinned at rc.115, so do not bump one package alone.
|
|
17
|
+
- 🔴 **`@effect/platform-node-shared` resolves to rc.118** under `@effect/platform-bun`
|
|
18
|
+
rc.115 on a fresh install, and the process dies at import (`Cannot find module
|
|
19
|
+
effect/process/ChildProcess`). Only a **root** `overrides` fixes it. An `overrides` field in
|
|
20
|
+
a workspace member's manifest, or in a tarball you install, is ignored (measured), which is
|
|
21
|
+
why this package declares none. `scripts/smoke.ts` installs platform-bun beside it with the
|
|
22
|
+
override above and asserts all three resolve to rc.115.
|
|
23
|
+
- ✅ `@modelcontextprotocol/sdk` 1.31.0 (the scout's measured pairing, `npm view` current on
|
|
24
|
+
2026-09-29): list, call and resource read against an Effect `McpServer`. Its `zod` peer
|
|
25
|
+
(`^3.25 || ^4.0`) is satisfied by the kit's zod, one copy in the lockfile, and the SDK's client
|
|
26
|
+
entry, bundled with workerd's resolution conditions, imports no `node:` module (`tests/pairing.test.ts`
|
|
27
|
+
asserts the zod pairing, `tests/sdk-neutral.test.ts` the bundle: it resolves the SDK's own
|
|
28
|
+
dependencies too, which the first regex walk of its import lines did not).
|
|
29
|
+
- ✅ Live, read-only (2026-09-29, the packed tarball on CT100): `mcpToolkit` connects to LiteLLM's
|
|
30
|
+
MCP gateway (`:4100/mcp`, a seat key as the bearer, an SSE reply to `initialize`) in 63 ms. ⚠️ That key
|
|
31
|
+
sees **0 tools and 0 resources**, so a live tool call through `mcpToolkit` is **not measured**.
|
|
32
|
+
- ✅ `@effect/sql-pg` rc.115 (2026-09-29, `npm view` and a scratch install): one peer (`effect ^rc.115`)
|
|
33
|
+
and no dependency: it speaks the Postgres wire protocol itself over `node:net`, so no driver package
|
|
34
|
+
joins the tree. It builds and queries under Bun against Postgres 18.6, and its `.d.ts` names
|
|
35
|
+
`node:stream` and `node:tls`, so a consumer with `skipLibCheck: false` and no `@types/node` sees 4
|
|
36
|
+
`TS2591` errors from it (upstream's; `scripts/smoke.ts` allows exactly those).
|
|
37
|
+
- ⚠️ `@effect/platform-bun` rc.115 ships `BunRedis`, but this package does **not** use it: it would make
|
|
38
|
+
`@effect/platform-bun` a dependency, and with it the `platform-node-shared` trap above for every
|
|
39
|
+
consumer. `SeatState.valkey` is the same `send` over `Bun.RedisClient` ([state.md](./state.md)).
|