@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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping one `Bun.RedisClient` connected for as long as its layer lives.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 BUN GIVES UP, AND A CLIENT THAT HAS GIVEN UP STAYS DEAD. Measured 2026-09-29 (Bun 1.4.0, a raw
|
|
5
|
+
* client with `enableOfflineQueue: false` and the default `maxRetries` of 20, the server killed
|
|
6
|
+
* for 60 s, then restarted): Bun retried on its own for 31 s (commands failed `Connection is
|
|
7
|
+
* closed and offline queue is disabled`), then GAVE UP: `onclose` ran once and every command
|
|
8
|
+
* after failed `Connection has failed`, still so 10 s after the server was back. Only calling
|
|
9
|
+
* `connect()` again revived it (it resolved at once and `PING` answered). The budget is a sum of
|
|
10
|
+
* backoffs, so it is not one number: the review that found this measured recovery after 30 s and
|
|
11
|
+
* none after 45 s. A seat whose Valkey restarts for a minute (a container restart, CT100 booting)
|
|
12
|
+
* would keep a dead client until the process restarted, and no retry in Effect could help, since
|
|
13
|
+
* every retry hit the same dead client.
|
|
14
|
+
* ★ SO THE LAYER RECONNECTS ITSELF, off `onclose`: single-flight, each attempt bounded by
|
|
15
|
+
* `connectionTimeout` (which does not bound DNS; see state-valkey.ts), backing off 250 ms up to
|
|
16
|
+
* 5 s between attempts, one `valkey reconnect` span per attempt and a warning and an info line
|
|
17
|
+
* around the outage. It stops when the layer's scope closes.
|
|
18
|
+
* ⚠️ `onclose` IS A HINT AND `connected` IS THE FACT. Measured on the same client: `onclose` also
|
|
19
|
+
* runs for every `connect()` that fails (each rejected after ~155 ms with `Connection closed`)
|
|
20
|
+
* and for the client's own `close()`, so a reconnect that trusted it would start itself. The
|
|
21
|
+
* loop therefore runs only while `connected` is false, and a wake-up that arrives after a
|
|
22
|
+
* successful reconnect finds nothing to do.
|
|
23
|
+
* ⚠️ COMMANDS STILL FAIL AT ONCE WHILE IT IS DOWN (the offline queue is off on purpose): the
|
|
24
|
+
* caller's retry is what spans an outage, and from now on the retry is answered once this loop
|
|
25
|
+
* has reconnected, within the pause plus one attempt of the server coming back.
|
|
26
|
+
*/
|
|
27
|
+
import type { RedisClient } from 'bun';
|
|
28
|
+
import * as Duration from 'effect/Duration';
|
|
29
|
+
import * as Effect from 'effect/Effect';
|
|
30
|
+
import * as Queue from 'effect/Queue';
|
|
31
|
+
import type * as Scope from 'effect/Scope';
|
|
32
|
+
|
|
33
|
+
/** What this file needs of `Bun.RedisClient`. */
|
|
34
|
+
export type Reconnectable = Pick<RedisClient, 'connected' | 'connect' | 'onclose'>;
|
|
35
|
+
|
|
36
|
+
export type KeepOptions = {
|
|
37
|
+
/** How long one reconnect attempt may take before it is abandoned and retried. */
|
|
38
|
+
readonly connectionTimeout: Duration.Input;
|
|
39
|
+
/** Span attributes every attempt carries: the server address, no secrets. */
|
|
40
|
+
readonly attributes: Readonly<Record<string, unknown>>;
|
|
41
|
+
/** The pause after the first failed attempt, doubled up to `longestPause`. Default 250 ms. */
|
|
42
|
+
readonly firstPause?: Duration.Input | undefined;
|
|
43
|
+
/** The longest pause between attempts. Default 5 s. */
|
|
44
|
+
readonly longestPause?: Duration.Input | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DEFAULT_FIRST_PAUSE_MS = 250;
|
|
48
|
+
const DEFAULT_LONGEST_PAUSE_MS = 5000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reconnect `client` whenever Bun gives up on it, for the life of the enclosing scope. Call it
|
|
52
|
+
* AFTER the first `connect()` succeeded, so a wrong URL or password still fails the layer's build
|
|
53
|
+
* instead of being retried forever.
|
|
54
|
+
*/
|
|
55
|
+
export const keepConnected: (
|
|
56
|
+
client: Reconnectable,
|
|
57
|
+
options: KeepOptions,
|
|
58
|
+
) => Effect.Effect<void, never, Scope.Scope> = Effect.fnUntraced(function* (
|
|
59
|
+
client: Reconnectable,
|
|
60
|
+
options: KeepOptions,
|
|
61
|
+
) {
|
|
62
|
+
const wake = yield* Queue.sliding<void>(1);
|
|
63
|
+
const firstPause = Duration.toMillis(options.firstPause ?? DEFAULT_FIRST_PAUSE_MS);
|
|
64
|
+
const longestPause = Duration.toMillis(options.longestPause ?? DEFAULT_LONGEST_PAUSE_MS);
|
|
65
|
+
// ⛔ ONE `connect()` IN FLIGHT AT A TIME. An attempt that times out is abandoned, but Bun's own
|
|
66
|
+
// promise is still pending (a name that does not resolve took 31 s), and a second `connect()`
|
|
67
|
+
// on top of it is not something Bun documents. The next attempt awaits the same promise.
|
|
68
|
+
let connecting: Promise<void> | undefined;
|
|
69
|
+
const connectOnce = (): Promise<void> => {
|
|
70
|
+
connecting ??= client.connect().finally(() => {
|
|
71
|
+
connecting = undefined;
|
|
72
|
+
});
|
|
73
|
+
return connecting;
|
|
74
|
+
};
|
|
75
|
+
const attempt = Effect.tryPromise({ try: connectOnce, catch: (cause) => cause }).pipe(
|
|
76
|
+
Effect.timeoutOrElse({
|
|
77
|
+
duration: options.connectionTimeout,
|
|
78
|
+
orElse: () => Effect.fail(new Error('a reconnect attempt did not finish in time')),
|
|
79
|
+
}),
|
|
80
|
+
Effect.withSpan('valkey reconnect', { kind: 'client', attributes: options.attributes }),
|
|
81
|
+
Effect.as(true),
|
|
82
|
+
Effect.orElseSucceed(() => false),
|
|
83
|
+
);
|
|
84
|
+
const reconnect = Effect.gen(function* () {
|
|
85
|
+
if (client.connected) return;
|
|
86
|
+
yield* Effect.logWarning('SeatState valkey: the connection is down, reconnecting');
|
|
87
|
+
let pause = firstPause;
|
|
88
|
+
let attempts = 0;
|
|
89
|
+
while (!client.connected) {
|
|
90
|
+
attempts += 1;
|
|
91
|
+
yield* attempt;
|
|
92
|
+
// ⚠️ `connected` decides, not the attempt's own answer: an attempt that resolved without the
|
|
93
|
+
// client being connected must pause like a failed one, not spin.
|
|
94
|
+
if (client.connected) break;
|
|
95
|
+
yield* Effect.sleep(pause);
|
|
96
|
+
pause = Math.min(pause * 2, longestPause);
|
|
97
|
+
}
|
|
98
|
+
yield* Effect.logInfo(`SeatState valkey: reconnected after ${String(attempts)} attempt(s)`);
|
|
99
|
+
});
|
|
100
|
+
client.onclose = () => {
|
|
101
|
+
Queue.offerUnsafe(wake, undefined);
|
|
102
|
+
};
|
|
103
|
+
// ⛔ A NO-OP, NEVER `null`: measured 2026-09-29 (Bun 1.4.0), `client.onclose = null` (or
|
|
104
|
+
// `undefined`) is accepted, and then `close()` calls the null and throws `TypeError: ... is not
|
|
105
|
+
// a function` (worded with whatever call site is on the stack, `c.close()` or a fiber's
|
|
106
|
+
// `this[args]()`). bun-types types `onclose` as `... | null`; Bun does not honour it.
|
|
107
|
+
yield* Effect.addFinalizer(() =>
|
|
108
|
+
Effect.sync(() => {
|
|
109
|
+
client.onclose = () => {};
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
yield* Effect.forkScoped(Effect.forever(Queue.take(wake).pipe(Effect.andThen(reconnect))));
|
|
113
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Valkey error may say once it has left the layer.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 THE SERVER'S ERROR TEXT ECHOES THE ARGUMENTS OF THE CALL THAT FAILED. Measured 2026-09-29
|
|
5
|
+
* (Valkey 9.1.1, through `Bun.RedisClient`, with canary values):
|
|
6
|
+
* ERR unknown command 'JSON.SET', with args beginning with: 'seat:key-…' '$' '{"secret":…}'
|
|
7
|
+
* ERR unknown subcommand 'seat:key-…'. Try OBJECT HELP.
|
|
8
|
+
* Keys are seat data and values are whatever the seat stored, and Effect's `OtlpTracer` exports
|
|
9
|
+
* every FAILED span's error as `exception.message` and `exception.stacktrace` (with the whole
|
|
10
|
+
* `cause` chain, `includeCauseInStack: true`), so those two errors put a key and a value into
|
|
11
|
+
* the exported trace: the OTLP payload a Victoria service would receive held both (a wire test
|
|
12
|
+
* in tests/state-error-text.test.ts asserts it now does not). A `Logger` that prints a failed
|
|
13
|
+
* call does the same.
|
|
14
|
+
* ★ SO THE ERROR THAT LEAVES THE LAYER IS A NEW `Error` WITH THE ARGUMENTS CUT OUT, and the
|
|
15
|
+
* original is not chained (a `cause` would carry its text straight back into the stack). Kept:
|
|
16
|
+
* Bun's `code` and `name`, and the server's own words up to the first quote that opens
|
|
17
|
+
* something other than the command that was sent. That keeps `NOPERM User seat has no
|
|
18
|
+
* permissions to run the 'flushall' command` whole (so `isPermissionDenied` and the wording are
|
|
19
|
+
* unchanged) and cuts `unknown command 'JSON.SET', with args beginning with: ` at the first
|
|
20
|
+
* argument, even when an argument holds a quote itself.
|
|
21
|
+
* ⚠️ WHAT THIS DOES NOT COVER, said plainly: a server text that echoes an argument WITHOUT quotes,
|
|
22
|
+
* and the text a Lua script raises itself (`error(...)` comes back as `ERR user_script:1: <text>`;
|
|
23
|
+
* measured). Bun's own errors (`Connection closed`, a timeout) quote nothing.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** What replaces the arguments. */
|
|
27
|
+
export const OMITTED = '[arguments omitted]';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `message` up to the first quoted thing that is not `command` (or one of its subcommands, which
|
|
31
|
+
* the server writes `command|sub`), with `OMITTED` where the rest was.
|
|
32
|
+
*/
|
|
33
|
+
export function cutArguments(message: string, command: string): string {
|
|
34
|
+
const name = command.toLowerCase();
|
|
35
|
+
let at = message.indexOf("'");
|
|
36
|
+
while (at !== -1) {
|
|
37
|
+
const close = message.indexOf("'", at + 1);
|
|
38
|
+
const quoted = close === -1 ? undefined : message.slice(at + 1, close).toLowerCase();
|
|
39
|
+
if (quoted === undefined || !(quoted === name || quoted.startsWith(`${name}|`))) {
|
|
40
|
+
return `${message.slice(0, at)}${OMITTED}`;
|
|
41
|
+
}
|
|
42
|
+
at = message.indexOf("'", close + 1);
|
|
43
|
+
}
|
|
44
|
+
return message;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The client's rejection as an `Error` that is safe to put in a span, a log or a `RedisError`:
|
|
49
|
+
* a fresh one, keeping Bun's `name` and `code`, with the arguments cut out of the text.
|
|
50
|
+
*/
|
|
51
|
+
export function scrubbedError(cause: unknown, command: string): Error {
|
|
52
|
+
const original = cause instanceof Error ? cause : undefined;
|
|
53
|
+
const scrubbed = new Error(cutArguments(original?.message ?? String(cause), command));
|
|
54
|
+
if (original !== undefined) {
|
|
55
|
+
scrubbed.name = original.name;
|
|
56
|
+
const code: unknown = (original as { readonly code?: unknown }).code;
|
|
57
|
+
if (typeof code === 'string') Object.assign(scrubbed, { code });
|
|
58
|
+
}
|
|
59
|
+
return scrubbed;
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Valkey command as a span, with a deadline, failing as `RedisError`.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A SPAN NAMES THE COMMAND, NEVER ITS ARGUMENTS. Keys are seat data and values are whatever the
|
|
5
|
+
* seat stored; `AUTH` and `HELLO ... AUTH` carry a password. Only the upper-cased command name
|
|
6
|
+
* (`SET`, `GET`) is recorded, so the span is low-cardinality and holds nothing to redact.
|
|
7
|
+
* 🔴 NOR IS THE ERROR TEXT: the server quotes the arguments of a call it refuses (`unknown command
|
|
8
|
+
* 'X', with args beginning with: '<key>' '<value>'`), and a failed span exports its error, so
|
|
9
|
+
* what a caller receives is `scrubbedError`, never Bun's own (state-valkey-scrub.ts).
|
|
10
|
+
* 🔴 THE DEADLINE IS NOT OPTIONAL. Measured 2026-09-29 (Bun 1.4.0): a `Bun.RedisClient` on its
|
|
11
|
+
* defaults that has lost its server QUEUES every command and never answers it until the server
|
|
12
|
+
* is back (a `send` sat unresolved past 8 s against a dead port; after a restart the queued
|
|
13
|
+
* command completed ~5 s later). With the offline queue off (`state-valkey.ts` sets it) a dropped
|
|
14
|
+
* connection fails at once, but a peer that holds the socket open and says nothing would still
|
|
15
|
+
* wedge a seat forever, so every command carries `commandTimeout` too.
|
|
16
|
+
*/
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as Redis from 'effect/unstable/persistence/Redis';
|
|
19
|
+
import type * as Duration from 'effect/Duration';
|
|
20
|
+
import { scrubbedError } from './state-valkey-scrub.ts';
|
|
21
|
+
|
|
22
|
+
/** What this file needs of a client: `Bun.RedisClient` has this exact `send`. */
|
|
23
|
+
export type ValkeyCall = {
|
|
24
|
+
readonly send: (command: string, args: Array<string>) => Promise<unknown>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** `send` as `Redis.make` wants it. */
|
|
28
|
+
export type Send = <A = unknown>(
|
|
29
|
+
command: string,
|
|
30
|
+
...args: ReadonlyArray<string>
|
|
31
|
+
) => Effect.Effect<A, Redis.RedisError>;
|
|
32
|
+
|
|
33
|
+
export type SendOptions = {
|
|
34
|
+
/** How long one command may take before it fails as `RedisError`. */
|
|
35
|
+
readonly commandTimeout: Duration.Input;
|
|
36
|
+
/** The command deadline in milliseconds, for the message only. */
|
|
37
|
+
readonly commandTimeoutMs: number;
|
|
38
|
+
/** Span attributes every command carries: `db.system.name`, the server address, no secrets. */
|
|
39
|
+
readonly attributes: Readonly<Record<string, unknown>>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function instrumentedSend(client: ValkeyCall, options: SendOptions): Send {
|
|
43
|
+
return <A = unknown>(command: string, ...args: ReadonlyArray<string>) => {
|
|
44
|
+
const name = command.toUpperCase();
|
|
45
|
+
return Effect.tryPromise({
|
|
46
|
+
// ⚠️ Bun's `send` types its argument list as a mutable array; ours is readonly.
|
|
47
|
+
try: () => client.send(command, [...args]) as Promise<A>,
|
|
48
|
+
catch: (cause) => new Redis.RedisError({ cause: scrubbedError(cause, command) }),
|
|
49
|
+
}).pipe(
|
|
50
|
+
Effect.timeoutOrElse({
|
|
51
|
+
duration: options.commandTimeout,
|
|
52
|
+
orElse: () =>
|
|
53
|
+
Effect.fail(
|
|
54
|
+
new Redis.RedisError({
|
|
55
|
+
cause: new Error(
|
|
56
|
+
`valkey ${name} did not answer within ${String(options.commandTimeoutMs)} ms`,
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
}),
|
|
61
|
+
Effect.withSpan(`valkey ${name}`, {
|
|
62
|
+
kind: 'client',
|
|
63
|
+
attributes: { ...options.attributes, 'db.operation.name': name },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valkey as Effect's own `Redis` service, over `Bun.RedisClient`.
|
|
3
|
+
*
|
|
4
|
+
* ★ `Redis.make` OVER THE BUILT-IN CLIENT, NOT `@effect/platform-bun`'s `BunRedis`. Rc.115 ships
|
|
5
|
+
* `BunRedis.layer`, and it would do; but a dependency on `@effect/platform-bun` drags
|
|
6
|
+
* `@effect/platform-node-shared ^rc.115` in behind it, which resolves to rc.118 on a fresh
|
|
7
|
+
* install and kills the process at import, and only a ROOT `overrides` fixes that (docs/pairing.md,
|
|
8
|
+
* measured 2026-09-29). A library cannot ship one. So this is the same ~30 lines (`send` over
|
|
9
|
+
* `client.send`), with the two things `BunRedis` lacks: a connection that must succeed before the
|
|
10
|
+
* layer is built, and a deadline on every command.
|
|
11
|
+
* ⛔ THE URL CARRIES THE ACL USER (`redis://seat:<password>@host:port`, percent-encoded), measured
|
|
12
|
+
* 2026-09-29 against a scratch Valkey with `user default off`: an unauthenticated client is
|
|
13
|
+
* refused `NOAUTH`; the seat user reads and writes its own prefix; a write outside it fails
|
|
14
|
+
* `NOPERM No permissions to access a key`, and a command outside its categories fails
|
|
15
|
+
* `NOPERM User seat has no permissions to run the 'flushall' command`. All of them arrive as
|
|
16
|
+
* `RedisError`, and `isPermissionDenied` names the two `NOPERM` ones.
|
|
17
|
+
* ⛔ NO `subscribe`. `Redis.subscribe` here fails with `RedisError`: a Valkey subscriber needs a
|
|
18
|
+
* connection of its own that this layer does not open. (`BunRedis` opens one, with no reconnect;
|
|
19
|
+
* a caller that needs pub/sub can use it beside this layer.)
|
|
20
|
+
* ⚠️ BUN ONLY, AND SAID SO AT THE FIRST USE. `Bun.RedisClient` is loaded with `import('bun')`, so
|
|
21
|
+
* importing this module under Node still works (the Postgres half is portable); BUILDING the
|
|
22
|
+
* Valkey layer there fails with a `RedisError` naming the reason.
|
|
23
|
+
* 🔴 `connectionTimeout` DOES NOT BOUND DNS. Measured 2026-09-29: with `connectionTimeout: 700`, a
|
|
24
|
+
* host name that does not resolve failed after 31 s. `connect()` therefore also runs under an
|
|
25
|
+
* Effect timeout of the same length.
|
|
26
|
+
* 🔴 BUN'S OWN RECONNECT ENDS, AND THE CLIENT THEN STAYS DEAD (about 31 s of outage here): the layer
|
|
27
|
+
* reconnects it itself, off `onclose` (state-valkey-connection.ts holds the measurement).
|
|
28
|
+
*/
|
|
29
|
+
import * as Config from 'effect/Config';
|
|
30
|
+
import * as Duration from 'effect/Duration';
|
|
31
|
+
import * as Effect from 'effect/Effect';
|
|
32
|
+
import * as Layer from 'effect/Layer';
|
|
33
|
+
import * as Redacted from 'effect/Redacted';
|
|
34
|
+
import * as Redis from 'effect/unstable/persistence/Redis';
|
|
35
|
+
import { valkeyFields } from './state-dsn.ts';
|
|
36
|
+
import { keepConnected } from './state-valkey-connection.ts';
|
|
37
|
+
import { instrumentedSend } from './state-valkey-send.ts';
|
|
38
|
+
|
|
39
|
+
export type ValkeyOptions = {
|
|
40
|
+
/**
|
|
41
|
+
* `redis://<user>:<password>@<host>:<port>[/<db>]` (or `valkey://`, `rediss://` for TLS), the
|
|
42
|
+
* user and password percent-encoded. Held `Redacted`; never in a span, log or error. Required:
|
|
43
|
+
* this package holds no host.
|
|
44
|
+
*/
|
|
45
|
+
readonly url: string | Redacted.Redacted<string>;
|
|
46
|
+
/**
|
|
47
|
+
* How long to wait to connect and authenticate when the layer is built, and for each reconnect
|
|
48
|
+
* attempt after Bun gives up. Default 5 s (Bun's own is 10 s). ⚠️ A finite positive duration of at
|
|
49
|
+
* most 2 ** 31 - 1 ms, or a `RangeError` defect.
|
|
50
|
+
*/
|
|
51
|
+
readonly connectionTimeout?: Duration.Input | undefined;
|
|
52
|
+
/** How long one command may take. Default 10 s; same limits as `connectionTimeout`. */
|
|
53
|
+
readonly commandTimeout?: Duration.Input | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* How many times Bun retries a lost connection before it gives up (its default is 20, about 30
|
|
56
|
+
* s of outage). After that THE LAYER reconnects until the server is back, so this only sets
|
|
57
|
+
* when its own loop takes over. A non-negative integer, or a `RangeError` defect.
|
|
58
|
+
*/
|
|
59
|
+
readonly maxRetries?: number | undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const DEFAULT_CONNECTION_TIMEOUT: Duration.Duration = Duration.seconds(5);
|
|
63
|
+
export const DEFAULT_COMMAND_TIMEOUT: Duration.Duration = Duration.seconds(10);
|
|
64
|
+
|
|
65
|
+
/** A duration as whole milliseconds Bun and `setTimeout` accept, or a `RangeError`. */
|
|
66
|
+
function milliseconds(input: Duration.Input, name: string): number {
|
|
67
|
+
const ms = Duration.toMillis(input);
|
|
68
|
+
if (!Number.isFinite(ms) || ms <= 0 || ms > 2 ** 31 - 1) {
|
|
69
|
+
throw new RangeError(`${name} must be a finite duration above 0 and at most 2 ** 31 - 1 ms`);
|
|
70
|
+
}
|
|
71
|
+
return Math.ceil(ms);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A failure that names the reason and never the URL. */
|
|
75
|
+
const refused = (reason: string): Redis.RedisError =>
|
|
76
|
+
new Redis.RedisError({ cause: new Error(`SeatState valkey: ${reason}`) });
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether `error` is the server's `NOPERM`: a key outside the user's prefix, or a command outside
|
|
80
|
+
* its categories. ⚠️ It reads the message, because Bun reports both as one `code`
|
|
81
|
+
* (`ERR_REDIS_SERVER_ERROR`) and the server's own text is the only thing that tells them apart
|
|
82
|
+
* from a real server fault.
|
|
83
|
+
*/
|
|
84
|
+
export function isPermissionDenied(error: unknown): boolean {
|
|
85
|
+
if (!(error instanceof Redis.RedisError)) return false;
|
|
86
|
+
const cause: unknown = error.cause;
|
|
87
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
88
|
+
return message.startsWith('NOPERM');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Subscribing is not offered; see the header. */
|
|
92
|
+
const subscribe = (): Effect.Effect<never, Redis.RedisError> =>
|
|
93
|
+
Effect.fail(refused('subscribe is not supported here (a subscriber needs its own connection)'));
|
|
94
|
+
|
|
95
|
+
/** Loads `Bun.RedisClient`, or fails naming why it is not there (Node, workerd). */
|
|
96
|
+
const loadClient = Effect.tryPromise({
|
|
97
|
+
try: async () => (await import('bun')).RedisClient,
|
|
98
|
+
catch: () => refused('needs Bun (Bun.RedisClient); this runtime does not have it'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const build = Effect.fnUntraced(function* (options: ValkeyOptions) {
|
|
102
|
+
const url = Redacted.value(
|
|
103
|
+
typeof options.url === 'string' ? Redacted.make(options.url) : options.url,
|
|
104
|
+
);
|
|
105
|
+
const fields = valkeyFields(url);
|
|
106
|
+
if (fields === undefined) {
|
|
107
|
+
return yield* refused('the URL is not a redis, valkey, rediss or valkeys URL');
|
|
108
|
+
}
|
|
109
|
+
const connectionTimeout = options.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT;
|
|
110
|
+
const commandTimeout = options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT;
|
|
111
|
+
const connectMs = milliseconds(connectionTimeout, 'connectionTimeout');
|
|
112
|
+
const commandMs = milliseconds(commandTimeout, 'commandTimeout');
|
|
113
|
+
const maxRetries = options.maxRetries;
|
|
114
|
+
if (maxRetries !== undefined && !(Number.isInteger(maxRetries) && maxRetries >= 0)) {
|
|
115
|
+
throw new RangeError('maxRetries must be a non-negative integer');
|
|
116
|
+
}
|
|
117
|
+
const RedisClient = yield* loadClient;
|
|
118
|
+
// ⛔ OFFLINE QUEUE OFF: a command sent while the connection is down fails at once as
|
|
119
|
+
// `RedisError` (a seat retries in Effect, where an attempt is a span) instead of waiting for
|
|
120
|
+
// a reconnect that may not come. Measured 2026-09-29: after a server restart the same client
|
|
121
|
+
// answered again within 1.5 s; past Bun's retry budget the layer reconnects behind the
|
|
122
|
+
// caller's retry (state-valkey-connection.ts). It is also why the layer connects first: an
|
|
123
|
+
// unconnected client with the queue off refuses every command.
|
|
124
|
+
const client = yield* Effect.acquireRelease(
|
|
125
|
+
Effect.try({
|
|
126
|
+
try: () =>
|
|
127
|
+
new RedisClient(url, {
|
|
128
|
+
connectionTimeout: connectMs,
|
|
129
|
+
enableOfflineQueue: false,
|
|
130
|
+
...(maxRetries === undefined ? {} : { maxRetries }),
|
|
131
|
+
}),
|
|
132
|
+
catch: () => refused('Bun.RedisClient refused the URL'),
|
|
133
|
+
}),
|
|
134
|
+
(opened) => Effect.sync(() => opened.close()),
|
|
135
|
+
);
|
|
136
|
+
yield* Effect.tryPromise({
|
|
137
|
+
try: () => client.connect(),
|
|
138
|
+
catch: (cause) => new Redis.RedisError({ cause }),
|
|
139
|
+
}).pipe(
|
|
140
|
+
Effect.timeoutOrElse({
|
|
141
|
+
duration: connectionTimeout,
|
|
142
|
+
orElse: () => Effect.fail(refused(`did not connect within ${String(connectMs)} ms`)),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
// ⚠️ Address and namespace only: the URL's user and password never reach an attribute.
|
|
146
|
+
const attributes = {
|
|
147
|
+
'db.system.name': 'redis',
|
|
148
|
+
...(fields.host === undefined ? {} : { 'server.address': fields.host }),
|
|
149
|
+
...(fields.port === undefined ? {} : { 'server.port': fields.port }),
|
|
150
|
+
...(fields.database === undefined ? {} : { 'db.namespace': fields.database }),
|
|
151
|
+
};
|
|
152
|
+
// ⛔ AFTER the first connect, so a wrong URL or password fails the build instead of being retried.
|
|
153
|
+
yield* keepConnected(client, { connectionTimeout, attributes });
|
|
154
|
+
return yield* Redis.make({
|
|
155
|
+
send: instrumentedSend(client, { commandTimeout, commandTimeoutMs: commandMs, attributes }),
|
|
156
|
+
subscribe,
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The `Redis` service against one Valkey. ⛔ BUILDING IT CONNECTS AND AUTHENTICATES, so a wrong
|
|
162
|
+
* URL, user or password fails at startup as `RedisError`, not at the first command (Bun reports a
|
|
163
|
+
* refused password as `Connection closed`, the same text as a server that is down).
|
|
164
|
+
* The client is closed when the layer's scope closes.
|
|
165
|
+
*/
|
|
166
|
+
export const valkey = (options: ValkeyOptions): Layer.Layer<Redis.Redis, Redis.RedisError> =>
|
|
167
|
+
Layer.effect(
|
|
168
|
+
Redis.Redis,
|
|
169
|
+
Effect.suspend(() => build(options)),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
/** The environment variable `valkeyFromEnv` reads unless told another. Not a host. */
|
|
173
|
+
export const VALKEY_URL_VARIABLE = 'SEAT_VALKEY_URL';
|
|
174
|
+
|
|
175
|
+
export type ValkeyFromEnvOptions = Omit<ValkeyOptions, 'url'> & {
|
|
176
|
+
/** The variable holding the URL. Default `SEAT_VALKEY_URL`. */
|
|
177
|
+
readonly variable?: string | undefined;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `valkey`, its URL read from an environment variable as a `Redacted` secret. A missing variable
|
|
182
|
+
* fails as `ConfigError`, naming the variable and nothing else.
|
|
183
|
+
*/
|
|
184
|
+
export const valkeyFromEnv = (
|
|
185
|
+
options?: ValkeyFromEnvOptions,
|
|
186
|
+
): Layer.Layer<Redis.Redis, Redis.RedisError | Config.ConfigError> => {
|
|
187
|
+
const { variable, ...rest } = options ?? {};
|
|
188
|
+
return Layer.unwrap(
|
|
189
|
+
Config.Redacted(variable ?? VALKEY_URL_VARIABLE).pipe(
|
|
190
|
+
Effect.map((url) => valkey({ ...rest, url })),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
};
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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';
|
package/src/version.ts
ADDED