@huynle/hub 0.6.0 → 0.7.1
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/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Kv in process memory (a cluster uses Valkey SET [PX] [NX], GET, GETDEL, DEL). Expiry is evaluated lazily
|
|
2
|
+
// against the clock, like the counters: no timers; expired keys are dropped when touched and in periodic sweeps.
|
|
3
|
+
import { ExpiringMap } from "./memory-counters"
|
|
4
|
+
import type { Clock, Kv, KvSetOptions } from "./types"
|
|
5
|
+
|
|
6
|
+
export class MemoryKv implements Kv {
|
|
7
|
+
private readonly now: Clock
|
|
8
|
+
private readonly values: ExpiringMap<{ value: string; expiresAt: number }>
|
|
9
|
+
|
|
10
|
+
constructor(options: { now?: Clock } = {}) {
|
|
11
|
+
this.now = options.now ?? Date.now
|
|
12
|
+
this.values = new ExpiringMap(this.now)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async set(key: string, value: string, options: KvSetOptions = {}): Promise<boolean> {
|
|
16
|
+
const { px, nx } = options
|
|
17
|
+
if (px !== undefined && !(Number.isInteger(px) && px > 0)) throw new Error("Kv.set: px must be a positive whole number of milliseconds")
|
|
18
|
+
if (nx && this.values.get(key)) return false
|
|
19
|
+
this.values.set(key, { value, expiresAt: px === undefined ? Infinity : this.now() + px })
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async get(key: string): Promise<string | undefined> {
|
|
24
|
+
return this.values.get(key)?.value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getdel(key: string): Promise<string | undefined> {
|
|
28
|
+
const entry = this.values.get(key)
|
|
29
|
+
if (entry) this.values.delete(key)
|
|
30
|
+
return entry?.value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async del(key: string): Promise<boolean> {
|
|
34
|
+
const existed = this.values.get(key) !== undefined
|
|
35
|
+
this.values.delete(key)
|
|
36
|
+
return existed
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// StateStore in process memory (the single-process hub; a cluster uses Postgres). Values are copied in and out,
|
|
2
|
+
// like a database round trip, so callers never share mutable state with the store.
|
|
3
|
+
import type { Claim, ClaimResult, Clock, Data, Entry, StateStore, Versioned, WriteOptions, WriteResult } from "./types"
|
|
4
|
+
|
|
5
|
+
interface Stored {
|
|
6
|
+
value: Data
|
|
7
|
+
version: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Map key for (a, b) pairs; NUL never occurs in ids or names. */
|
|
11
|
+
const pair = (a: string, b: string) => `${a}\u0000${b}`
|
|
12
|
+
|
|
13
|
+
export class MemoryStateStore implements StateStore {
|
|
14
|
+
private readonly now: Clock
|
|
15
|
+
/** pair(userID, collection) → key → entry. */
|
|
16
|
+
private readonly collections = new Map<string, Map<string, Stored>>()
|
|
17
|
+
/** pair(namespace, key) → claim. */
|
|
18
|
+
private readonly claims = new Map<string, Claim>()
|
|
19
|
+
|
|
20
|
+
constructor(options: { now?: Clock } = {}) {
|
|
21
|
+
this.now = options.now ?? Date.now
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async get<T = Data>(userID: string, collection: string, key: string): Promise<Versioned<T> | undefined> {
|
|
25
|
+
const stored = this.collections.get(pair(userID, collection))?.get(key)
|
|
26
|
+
return stored && { value: structuredClone(stored.value) as T, version: stored.version }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async put<T = Data>(userID: string, collection: string, key: string, value: T, options: WriteOptions = {}): Promise<WriteResult> {
|
|
30
|
+
const id = pair(userID, collection)
|
|
31
|
+
let entries = this.collections.get(id)
|
|
32
|
+
const current = entries?.get(key)?.version ?? 0
|
|
33
|
+
if (options.ifVersion !== undefined && options.ifVersion !== current) return { ok: false, version: current }
|
|
34
|
+
if (!entries) this.collections.set(id, (entries = new Map()))
|
|
35
|
+
const version = current + 1
|
|
36
|
+
entries.set(key, { value: structuredClone(value), version })
|
|
37
|
+
return { ok: true, version }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async delete(userID: string, collection: string, key: string, options: WriteOptions = {}): Promise<WriteResult> {
|
|
41
|
+
const id = pair(userID, collection)
|
|
42
|
+
const entries = this.collections.get(id)
|
|
43
|
+
const current = entries?.get(key)?.version ?? 0
|
|
44
|
+
if (options.ifVersion !== undefined && options.ifVersion !== current) return { ok: false, version: current }
|
|
45
|
+
entries?.delete(key)
|
|
46
|
+
if (entries?.size === 0) this.collections.delete(id)
|
|
47
|
+
return { ok: true, version: 0 }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async list<T = Data>(userID: string, collection: string): Promise<Entry<T>[]> {
|
|
51
|
+
const entries = this.collections.get(pair(userID, collection))
|
|
52
|
+
if (!entries) return []
|
|
53
|
+
return [...entries.entries()]
|
|
54
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
55
|
+
.map(([key, stored]) => ({ key, value: structuredClone(stored.value) as T, version: stored.version }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async claim(namespace: string, key: string, owner: string): Promise<ClaimResult> {
|
|
59
|
+
const id = pair(namespace, key)
|
|
60
|
+
const held = this.claims.get(id)
|
|
61
|
+
if (held) return held.owner === owner ? { ok: true, claim: held } : { ok: false, conflict: held }
|
|
62
|
+
const claim: Claim = { namespace, key, owner, at: this.now() }
|
|
63
|
+
this.claims.set(id, claim)
|
|
64
|
+
return { ok: true, claim }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async release(namespace: string, key: string, owner: string): Promise<boolean> {
|
|
68
|
+
const id = pair(namespace, key)
|
|
69
|
+
if (this.claims.get(id)?.owner !== owner) return false
|
|
70
|
+
this.claims.delete(id)
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async claimOf(namespace: string, key: string): Promise<Claim | undefined> {
|
|
75
|
+
return this.claims.get(pair(namespace, key))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Postgres schema for cluster mode (decision vkxkqiuf §3, design §10). Migrations are ordered, recorded in
|
|
2
|
+
// hub_schema and applied by `hub-server migrate` (a Job) under a session advisory lock, so concurrent runs apply
|
|
3
|
+
// each one exactly once; each runs in its own transaction. A database newer than the code is left alone: every
|
|
4
|
+
// schema must keep working with the previous release. Nothing here names the database URL.
|
|
5
|
+
import type { SQL } from "bun"
|
|
6
|
+
|
|
7
|
+
export interface Migration {
|
|
8
|
+
/** 1, 2, 3… in order. */
|
|
9
|
+
readonly version: number
|
|
10
|
+
readonly name: string
|
|
11
|
+
/** SQL statements, run in one transaction. */
|
|
12
|
+
readonly up: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MIGRATIONS: readonly Migration[] = [
|
|
16
|
+
{
|
|
17
|
+
version: 1,
|
|
18
|
+
name: "state-and-claims",
|
|
19
|
+
up: `
|
|
20
|
+
CREATE TABLE hub_state (
|
|
21
|
+
user_id text COLLATE "C" NOT NULL,
|
|
22
|
+
collection text COLLATE "C" NOT NULL,
|
|
23
|
+
key text COLLATE "C" NOT NULL,
|
|
24
|
+
value jsonb NOT NULL,
|
|
25
|
+
version integer NOT NULL CHECK (version > 0),
|
|
26
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
27
|
+
PRIMARY KEY (user_id, collection, key)
|
|
28
|
+
);
|
|
29
|
+
CREATE TABLE hub_claims (
|
|
30
|
+
namespace text COLLATE "C" NOT NULL,
|
|
31
|
+
key text COLLATE "C" NOT NULL,
|
|
32
|
+
owner text NOT NULL,
|
|
33
|
+
at bigint NOT NULL,
|
|
34
|
+
PRIMARY KEY (namespace, key)
|
|
35
|
+
);
|
|
36
|
+
`,
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** The schema version this code needs. */
|
|
41
|
+
export const SCHEMA_VERSION = MIGRATIONS.at(-1)?.version ?? 0
|
|
42
|
+
|
|
43
|
+
/** pg_advisory_lock key of the migrations ("hubsch" in ASCII). */
|
|
44
|
+
export const MIGRATION_LOCK_KEY = 0x6875_6273_6368
|
|
45
|
+
|
|
46
|
+
const SCHEMA_TABLE = `
|
|
47
|
+
CREATE TABLE IF NOT EXISTS hub_schema (
|
|
48
|
+
version integer PRIMARY KEY,
|
|
49
|
+
name text NOT NULL,
|
|
50
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
51
|
+
)
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
/** The database's schema version: the latest applied migration (0 before the first). */
|
|
55
|
+
export async function schemaVersion(sql: SQL): Promise<number> {
|
|
56
|
+
const [exists] = (await sql`SELECT to_regclass('public.hub_schema') IS NOT NULL AS ok`) as { ok: boolean }[]
|
|
57
|
+
if (!exists?.ok) return 0
|
|
58
|
+
const [row] = (await sql`SELECT coalesce(max(version), 0)::int AS version FROM hub_schema`) as { version: number }[]
|
|
59
|
+
return row?.version ?? 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MigrateResult {
|
|
63
|
+
/** The versions this run applied (empty when another run or an earlier one did). */
|
|
64
|
+
readonly applied: number[]
|
|
65
|
+
/** The schema version afterwards. */
|
|
66
|
+
readonly version: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Applies the pending migrations under the advisory lock; idempotent and safe to run concurrently. */
|
|
70
|
+
export async function migrate(sql: SQL, options: { readonly migrations?: readonly Migration[] } = {}): Promise<MigrateResult> {
|
|
71
|
+
const migrations = options.migrations ?? MIGRATIONS
|
|
72
|
+
const conn = await sql.reserve()
|
|
73
|
+
try {
|
|
74
|
+
await conn`SELECT pg_advisory_lock(${String(MIGRATION_LOCK_KEY)}::bigint)`
|
|
75
|
+
try {
|
|
76
|
+
await conn.unsafe(SCHEMA_TABLE)
|
|
77
|
+
const done = new Set(((await conn`SELECT version FROM hub_schema`) as { version: number }[]).map((r) => r.version))
|
|
78
|
+
const applied: number[] = []
|
|
79
|
+
for (const migration of migrations) {
|
|
80
|
+
if (done.has(migration.version)) continue
|
|
81
|
+
// BEGIN/COMMIT by hand: Bun 1.3.5's reserved-connection begin() also leaves the failure unhandled.
|
|
82
|
+
await conn.unsafe("BEGIN")
|
|
83
|
+
try {
|
|
84
|
+
await conn.unsafe(migration.up)
|
|
85
|
+
await conn`INSERT INTO hub_schema (version, name) VALUES (${migration.version}, ${migration.name})`
|
|
86
|
+
await conn.unsafe("COMMIT")
|
|
87
|
+
} catch (error) {
|
|
88
|
+
await conn.unsafe("ROLLBACK").catch(() => {})
|
|
89
|
+
throw new Error(`migration ${migration.version} (${migration.name}) failed: ${(error as Error).message}`, { cause: error })
|
|
90
|
+
}
|
|
91
|
+
applied.push(migration.version)
|
|
92
|
+
}
|
|
93
|
+
return { applied, version: await schemaVersion(conn) }
|
|
94
|
+
} finally {
|
|
95
|
+
await conn`SELECT pg_advisory_unlock(${String(MIGRATION_LOCK_KEY)}::bigint)`
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
conn.release()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// StateStore in Postgres (cluster mode; decision vkxkqiuf §3). Entries live in one jsonb table, hub_state
|
|
2
|
+
// (user_id, collection, key, value, version); unique claims in hub_claims with the primary key as the unique
|
|
3
|
+
// constraint. Versions change in single statements, so concurrent pods never lose a write:
|
|
4
|
+
// - put without ifVersion: an upsert that bumps the version
|
|
5
|
+
// - put with ifVersion 0: INSERT … ON CONFLICT DO NOTHING
|
|
6
|
+
// - put with ifVersion n: UPDATE … WHERE version = n (a concurrent writer re-checks the row it waited for)
|
|
7
|
+
// and a refused write reports the version read right after. The client connects on first use (the hub builds its
|
|
8
|
+
// backends synchronously) and first checks the schema (see ./postgres-migrate), or applies it with `migrate`.
|
|
9
|
+
// The URL holds credentials: it stays in a private field and is never put in a message.
|
|
10
|
+
import { SQL } from "bun"
|
|
11
|
+
import { migrate, SCHEMA_VERSION, schemaVersion } from "./postgres-migrate"
|
|
12
|
+
import type { Claim, ClaimResult, Clock, Data, Entry, StateStore, Versioned, WriteOptions, WriteResult } from "./types"
|
|
13
|
+
|
|
14
|
+
export interface PostgresStateStoreOptions {
|
|
15
|
+
/** postgres:// or postgresql:// (with credentials). */
|
|
16
|
+
readonly url: string
|
|
17
|
+
readonly now?: Clock
|
|
18
|
+
/** Apply pending migrations on first use (default false: they must have run, `hub-server migrate`). */
|
|
19
|
+
readonly migrate?: boolean
|
|
20
|
+
/** Pool size (default 10). */
|
|
21
|
+
readonly max?: number
|
|
22
|
+
/** Seconds to wait for a connection (default Bun's). */
|
|
23
|
+
readonly connectionTimeoutSeconds?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The database is behind the schema this hub needs. The message names no URL. */
|
|
27
|
+
export class SchemaOutdatedError extends Error {
|
|
28
|
+
constructor(readonly found: number, readonly needed: number) {
|
|
29
|
+
super(`The Postgres schema is at version ${found}, this hub needs ${needed}: run \`hub-server migrate\``)
|
|
30
|
+
this.name = "SchemaOutdatedError"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Attempts of a conditional write whose row changed between the write and the version read. */
|
|
35
|
+
const RACE_RETRIES = 8
|
|
36
|
+
|
|
37
|
+
interface Row {
|
|
38
|
+
value: string
|
|
39
|
+
version: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ClaimRow {
|
|
43
|
+
owner: string
|
|
44
|
+
at: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class PostgresStateStore implements StateStore {
|
|
48
|
+
readonly #url: string
|
|
49
|
+
readonly #options: Omit<PostgresStateStoreOptions, "url">
|
|
50
|
+
readonly #now: Clock
|
|
51
|
+
#sql?: SQL
|
|
52
|
+
#ready?: Promise<void>
|
|
53
|
+
|
|
54
|
+
constructor(options: PostgresStateStoreOptions) {
|
|
55
|
+
const { url, ...rest } = options
|
|
56
|
+
this.#url = url
|
|
57
|
+
this.#options = rest
|
|
58
|
+
this.#now = options.now ?? Date.now
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async get<T = Data>(userID: string, collection: string, key: string): Promise<Versioned<T> | undefined> {
|
|
62
|
+
const sql = await this.#client()
|
|
63
|
+
const [row] = (await sql`
|
|
64
|
+
SELECT value::text AS value, version FROM hub_state
|
|
65
|
+
WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key}`) as Row[]
|
|
66
|
+
return row && { value: JSON.parse(row.value) as T, version: row.version }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async put<T = Data>(userID: string, collection: string, key: string, value: T, options: WriteOptions = {}): Promise<WriteResult> {
|
|
70
|
+
const sql = await this.#client()
|
|
71
|
+
const json = JSON.stringify(value ?? null)
|
|
72
|
+
const expected = options.ifVersion
|
|
73
|
+
if (expected === undefined) {
|
|
74
|
+
const [row] = (await sql`
|
|
75
|
+
INSERT INTO hub_state (user_id, collection, key, value, version)
|
|
76
|
+
VALUES (${userID}, ${collection}, ${key}, (${json}::text)::jsonb, 1)
|
|
77
|
+
ON CONFLICT (user_id, collection, key)
|
|
78
|
+
DO UPDATE SET value = EXCLUDED.value, version = hub_state.version + 1, updated_at = now()
|
|
79
|
+
RETURNING version`) as { version: number }[]
|
|
80
|
+
return { ok: true, version: row!.version }
|
|
81
|
+
}
|
|
82
|
+
for (let attempt = 0; ; attempt++) {
|
|
83
|
+
const rows = (expected === 0
|
|
84
|
+
? await sql`
|
|
85
|
+
INSERT INTO hub_state (user_id, collection, key, value, version)
|
|
86
|
+
VALUES (${userID}, ${collection}, ${key}, (${json}::text)::jsonb, 1)
|
|
87
|
+
ON CONFLICT (user_id, collection, key) DO NOTHING
|
|
88
|
+
RETURNING version`
|
|
89
|
+
: await sql`
|
|
90
|
+
UPDATE hub_state SET value = (${json}::text)::jsonb, version = version + 1, updated_at = now()
|
|
91
|
+
WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key} AND version = ${expected}
|
|
92
|
+
RETURNING version`) as { version: number }[]
|
|
93
|
+
if (rows[0]) return { ok: true, version: rows[0].version }
|
|
94
|
+
const current = await this.#version(sql, userID, collection, key)
|
|
95
|
+
// The row changed back to the expected version in between (deleted and recreated): try again.
|
|
96
|
+
if (current !== expected || attempt >= RACE_RETRIES) return { ok: false, version: current }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async delete(userID: string, collection: string, key: string, options: WriteOptions = {}): Promise<WriteResult> {
|
|
101
|
+
const sql = await this.#client()
|
|
102
|
+
const expected = options.ifVersion
|
|
103
|
+
if (expected === undefined) {
|
|
104
|
+
await sql`DELETE FROM hub_state WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key}`
|
|
105
|
+
return { ok: true, version: 0 }
|
|
106
|
+
}
|
|
107
|
+
for (let attempt = 0; ; attempt++) {
|
|
108
|
+
if (expected !== 0) {
|
|
109
|
+
const rows = await sql`
|
|
110
|
+
DELETE FROM hub_state
|
|
111
|
+
WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key} AND version = ${expected}
|
|
112
|
+
RETURNING version`
|
|
113
|
+
if (rows.length) return { ok: true, version: 0 }
|
|
114
|
+
}
|
|
115
|
+
const current = await this.#version(sql, userID, collection, key)
|
|
116
|
+
if (expected === 0 && current === 0) return { ok: true, version: 0 }
|
|
117
|
+
if (current !== expected || attempt >= RACE_RETRIES) return { ok: false, version: current }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async list<T = Data>(userID: string, collection: string): Promise<Entry<T>[]> {
|
|
122
|
+
const sql = await this.#client()
|
|
123
|
+
const rows = (await sql`
|
|
124
|
+
SELECT key, value::text AS value, version FROM hub_state
|
|
125
|
+
WHERE user_id = ${userID} AND collection = ${collection}`) as (Row & { key: string })[]
|
|
126
|
+
// Sorted here, by UTF-16 code units like the other stores (not by a database collation).
|
|
127
|
+
return rows
|
|
128
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
129
|
+
.map((row) => ({ key: row.key, value: JSON.parse(row.value) as T, version: row.version }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async claim(namespace: string, key: string, owner: string): Promise<ClaimResult> {
|
|
133
|
+
const sql = await this.#client()
|
|
134
|
+
for (let attempt = 0; ; attempt++) {
|
|
135
|
+
const [inserted] = (await sql`
|
|
136
|
+
INSERT INTO hub_claims (namespace, key, owner, at) VALUES (${namespace}, ${key}, ${owner}, ${this.#now()})
|
|
137
|
+
ON CONFLICT (namespace, key) DO NOTHING
|
|
138
|
+
RETURNING owner, at::float8 AS at`) as ClaimRow[]
|
|
139
|
+
if (inserted) return { ok: true, claim: { namespace, key, owner: inserted.owner, at: inserted.at } }
|
|
140
|
+
const held = await this.claimOf(namespace, key)
|
|
141
|
+
if (held) return held.owner === owner ? { ok: true, claim: held } : { ok: false, conflict: held }
|
|
142
|
+
// Released in between: claim again.
|
|
143
|
+
if (attempt >= RACE_RETRIES) throw new Error(`claim ${namespace}/${key} kept changing; try again`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async release(namespace: string, key: string, owner: string): Promise<boolean> {
|
|
148
|
+
const sql = await this.#client()
|
|
149
|
+
const rows = await sql`DELETE FROM hub_claims WHERE namespace = ${namespace} AND key = ${key} AND owner = ${owner} RETURNING owner`
|
|
150
|
+
return rows.length > 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async claimOf(namespace: string, key: string): Promise<Claim | undefined> {
|
|
154
|
+
const sql = await this.#client()
|
|
155
|
+
const [row] = (await sql`SELECT owner, at::float8 AS at FROM hub_claims WHERE namespace = ${namespace} AND key = ${key}`) as ClaimRow[]
|
|
156
|
+
return row && { namespace, key, owner: row.owner, at: row.at }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Closes the connection pool (a later call opens a new one). */
|
|
160
|
+
/** Readiness: SELECT 1 (after the schema check; rejects when the database does not answer, never naming the url). */
|
|
161
|
+
async ping(): Promise<void> {
|
|
162
|
+
const sql = await this.#client()
|
|
163
|
+
await sql`SELECT 1`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async close(): Promise<void> {
|
|
167
|
+
const sql = this.#sql
|
|
168
|
+
this.#sql = undefined
|
|
169
|
+
this.#ready = undefined
|
|
170
|
+
await sql?.close()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async #version(sql: SQL, userID: string, collection: string, key: string): Promise<number> {
|
|
174
|
+
const [row] = (await sql`
|
|
175
|
+
SELECT version FROM hub_state WHERE user_id = ${userID} AND collection = ${collection} AND key = ${key}`) as { version: number }[]
|
|
176
|
+
return row?.version ?? 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The pool, created on first use, once the schema is current; a failed check is retried by the next call. */
|
|
180
|
+
async #client(): Promise<SQL> {
|
|
181
|
+
const sql = (this.#sql ??= new SQL(this.#url, {
|
|
182
|
+
max: this.#options.max ?? 10,
|
|
183
|
+
...(this.#options.connectionTimeoutSeconds !== undefined ? { connectionTimeout: this.#options.connectionTimeoutSeconds } : {}),
|
|
184
|
+
}))
|
|
185
|
+
this.#ready ??= this.#checkSchema(sql).catch((error: unknown) => {
|
|
186
|
+
this.#ready = undefined
|
|
187
|
+
throw error
|
|
188
|
+
})
|
|
189
|
+
await this.#ready
|
|
190
|
+
return sql
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async #checkSchema(sql: SQL): Promise<void> {
|
|
194
|
+
const version = this.#options.migrate ? (await migrate(sql)).version : await schemaVersion(sql)
|
|
195
|
+
if (version < SCHEMA_VERSION) throw new SchemaOutdatedError(version, SCHEMA_VERSION)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Backend interfaces for state the hub shares between processes (design §9). The single-process hub uses the
|
|
2
|
+
// in-memory implementations (./memory-*); a cluster swaps in Postgres / Valkey ones behind the same contracts.
|
|
3
|
+
// Everything is async so the networked implementations fit. Only new code uses these: existing state (Store,
|
|
4
|
+
// the live-stream EventLog, the Registry) stays where it is.
|
|
5
|
+
|
|
6
|
+
/** Milliseconds since the epoch (injectable for tests). */
|
|
7
|
+
export type Clock = () => number
|
|
8
|
+
|
|
9
|
+
/** Timer functions (injectable for tests); the same shape as the Registry's. */
|
|
10
|
+
export interface Timers {
|
|
11
|
+
set(fn: () => void, ms: number): unknown
|
|
12
|
+
clear(handle: unknown): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Values the backends store or carry: JSON-compatible data (implementations may serialize them). */
|
|
16
|
+
export type Data = unknown
|
|
17
|
+
|
|
18
|
+
// ---- StateStore -------------------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** A stored value and its version (1 for the first write, +1 for every later one). */
|
|
21
|
+
export interface Versioned<T = Data> {
|
|
22
|
+
readonly value: T
|
|
23
|
+
readonly version: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Entry<T = Data> extends Versioned<T> {
|
|
27
|
+
readonly key: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Optimistic concurrency: `ifVersion` is the version the caller read (0: the key must not exist yet). */
|
|
31
|
+
export interface WriteOptions {
|
|
32
|
+
readonly ifVersion?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A write's outcome; on a version mismatch, the version the key has now (0: it does not exist). */
|
|
36
|
+
export type WriteResult = { readonly ok: true; readonly version: number } | { readonly ok: false; readonly version: number }
|
|
37
|
+
|
|
38
|
+
/** Who holds a unique name (an @name, an exclusive role) in a namespace. */
|
|
39
|
+
export interface Claim {
|
|
40
|
+
readonly namespace: string
|
|
41
|
+
readonly key: string
|
|
42
|
+
readonly owner: string
|
|
43
|
+
readonly at: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A claim's outcome: the claim, or (conflict) the claim someone else already holds. */
|
|
47
|
+
export type ClaimResult = { readonly ok: true; readonly claim: Claim } | { readonly ok: false; readonly conflict: Claim }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Durable agent data, per user and collection, with optimistic versions; plus unique claims (a database unique
|
|
51
|
+
* constraint in a cluster).
|
|
52
|
+
*/
|
|
53
|
+
export interface StateStore {
|
|
54
|
+
get<T = Data>(userID: string, collection: string, key: string): Promise<Versioned<T> | undefined>
|
|
55
|
+
put<T = Data>(userID: string, collection: string, key: string, value: T, options?: WriteOptions): Promise<WriteResult>
|
|
56
|
+
/** Removes a key; `ok: false` on a version mismatch. Removing a missing key (without ifVersion) is ok. */
|
|
57
|
+
delete(userID: string, collection: string, key: string, options?: WriteOptions): Promise<WriteResult>
|
|
58
|
+
/** The user's entries in a collection, by key. */
|
|
59
|
+
list<T = Data>(userID: string, collection: string): Promise<Entry<T>[]>
|
|
60
|
+
/** Claims `key` in `namespace` for `owner`; claiming again for the same owner is ok (the original claim). */
|
|
61
|
+
claim(namespace: string, key: string, owner: string): Promise<ClaimResult>
|
|
62
|
+
/** Gives a claim up; false when `owner` does not hold it. */
|
|
63
|
+
release(namespace: string, key: string, owner: string): Promise<boolean>
|
|
64
|
+
claimOf(namespace: string, key: string): Promise<Claim | undefined>
|
|
65
|
+
/** Makes buffered writes durable (the hub calls it on stop); stores that write through omit it. */
|
|
66
|
+
flush?(): Promise<void>
|
|
67
|
+
/** Readiness (/readyz): rejects when the database does not answer. In-process stores omit it. */
|
|
68
|
+
ping?(): Promise<void>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- Bus --------------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export type Unsubscribe = () => Promise<void>
|
|
74
|
+
export type BusHandler = (message: Data, topic: string) => void | Promise<void>
|
|
75
|
+
export type BusResponder = (message: Data, topic: string) => Data | Promise<Data>
|
|
76
|
+
|
|
77
|
+
export interface RequestOptions {
|
|
78
|
+
readonly timeoutMs: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cross-process messages: fire-and-forget publish/subscribe, and request/reply (RPC to the process holding a
|
|
83
|
+
* connection). Delivery is at most once; a subscriber's failure never reaches the publisher.
|
|
84
|
+
*/
|
|
85
|
+
export interface Bus {
|
|
86
|
+
/** Sends a message to the topic's current subscribers; resolves with how many there were. */
|
|
87
|
+
publish(topic: string, message: Data): Promise<number>
|
|
88
|
+
subscribe(topic: string, handler: BusHandler): Promise<Unsubscribe>
|
|
89
|
+
/** Answers requests on a topic (one responder per topic). */
|
|
90
|
+
serve(topic: string, responder: BusResponder): Promise<Unsubscribe>
|
|
91
|
+
/**
|
|
92
|
+
* Asks the topic's responder; rejects with BusNoResponderError when there is none, BusTimeoutError after
|
|
93
|
+
* `timeoutMs`, or BusRemoteError when the responder throws.
|
|
94
|
+
*/
|
|
95
|
+
request<T = Data>(topic: string, message: Data, options: RequestOptions): Promise<T>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class BusTimeoutError extends Error {
|
|
99
|
+
constructor(readonly topic: string, readonly timeoutMs: number) {
|
|
100
|
+
super(`no reply on ${topic} within ${timeoutMs} ms`)
|
|
101
|
+
this.name = "BusTimeoutError"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class BusNoResponderError extends Error {
|
|
106
|
+
constructor(readonly topic: string) {
|
|
107
|
+
super(`no responder on ${topic}`)
|
|
108
|
+
this.name = "BusNoResponderError"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class BusRemoteError extends Error {
|
|
113
|
+
constructor(readonly topic: string, message: string) {
|
|
114
|
+
super(message)
|
|
115
|
+
this.name = "BusRemoteError"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- EventLog ---------------------------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export type EventMessage = { type: string; [key: string]: unknown }
|
|
122
|
+
|
|
123
|
+
export interface LogEvent {
|
|
124
|
+
readonly id: string
|
|
125
|
+
readonly message: EventMessage
|
|
126
|
+
/** Not for this device (the one that made the change). */
|
|
127
|
+
readonly exceptDevice?: string
|
|
128
|
+
readonly at: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The events after an id, or `resync` when the log cannot cover the gap (the client reloads). */
|
|
132
|
+
export type SinceResult = { readonly events: LogEvent[] } | { readonly resync: true }
|
|
133
|
+
|
|
134
|
+
/** Per-user replay of the live stream, bounded by count and age (a Redis Stream in a cluster). */
|
|
135
|
+
export interface EventLog {
|
|
136
|
+
append(userID: string, message: EventMessage, options?: { readonly exceptDevice?: string }): Promise<string>
|
|
137
|
+
/** The id of the user's latest event; a client holding it is covered until events are dropped. */
|
|
138
|
+
latest(userID: string): Promise<string>
|
|
139
|
+
since(userID: string, lastID: string): Promise<SinceResult>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- Counters / Locks -------------------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** Fixed-window counters (rate limits, budgets): INCR + PEXPIRE NX in a cluster. */
|
|
145
|
+
export interface Counters {
|
|
146
|
+
/** Adds one and returns the count; the first increment of a window starts its `ttlMs`. */
|
|
147
|
+
incr(key: string, ttlMs: number): Promise<number>
|
|
148
|
+
/** The current count (0 when unset or expired). */
|
|
149
|
+
get(key: string): Promise<number>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A held lock. `fence` grows with every new acquisition of a key (fencing token). */
|
|
153
|
+
export interface Lease {
|
|
154
|
+
readonly key: string
|
|
155
|
+
readonly owner: string
|
|
156
|
+
readonly fence: number
|
|
157
|
+
readonly expiresAt: number
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Expiring exclusive leases (SET NX PX in a cluster). */
|
|
161
|
+
export interface Locks {
|
|
162
|
+
/** Takes the lock, or undefined while someone else holds it; the holder acquiring again renews. */
|
|
163
|
+
acquire(key: string, owner: string, ttlMs: number): Promise<Lease | undefined>
|
|
164
|
+
/** Extends the owner's lease; undefined when it no longer holds the lock (expired or taken). */
|
|
165
|
+
renew(key: string, owner: string, ttlMs: number): Promise<Lease | undefined>
|
|
166
|
+
/** Releases the owner's lease; false when it does not hold it. */
|
|
167
|
+
release(key: string, owner: string): Promise<boolean>
|
|
168
|
+
holder(key: string): Promise<Lease | undefined>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- Kv ---------------------------------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/** `set` options: `px` expires the key after that many milliseconds; `nx` writes only when the key is absent. */
|
|
174
|
+
export interface KvSetOptions {
|
|
175
|
+
readonly px?: number
|
|
176
|
+
readonly nx?: boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Short-lived shared strings (pairing and sign-in codes, OIDC state, directory entries): SET [PX] [NX], GET,
|
|
181
|
+
* GETDEL and DEL in a cluster. Values are strings; callers encode structured data themselves.
|
|
182
|
+
*/
|
|
183
|
+
export interface Kv {
|
|
184
|
+
/** Writes the value (dropping any earlier expiry unless `px` is given); false when `nx` and the key exists. */
|
|
185
|
+
set(key: string, value: string, options?: KvSetOptions): Promise<boolean>
|
|
186
|
+
get(key: string): Promise<string | undefined>
|
|
187
|
+
/** Reads and removes the key in one step (a one-time code is redeemed once). */
|
|
188
|
+
getdel(key: string): Promise<string | undefined>
|
|
189
|
+
/** Removes the key; true when it existed. */
|
|
190
|
+
del(key: string): Promise<boolean>
|
|
191
|
+
/** Readiness (/readyz): rejects when the server does not answer. In-process stores omit it. */
|
|
192
|
+
ping?(): Promise<void>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Catalog ----------------------------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export type CatalogValue = Record<string, unknown>
|
|
198
|
+
|
|
199
|
+
/** What one connection reports about a session. */
|
|
200
|
+
export interface CatalogEntry<T extends CatalogValue = CatalogValue> {
|
|
201
|
+
readonly sessionID: string
|
|
202
|
+
readonly connID: string
|
|
203
|
+
readonly value: T
|
|
204
|
+
readonly at: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A session as all its connections report it, merged. */
|
|
208
|
+
export interface CatalogSession<T extends CatalogValue = CatalogValue> {
|
|
209
|
+
readonly sessionID: string
|
|
210
|
+
readonly value: T
|
|
211
|
+
/** The connections reporting it, oldest report first. */
|
|
212
|
+
readonly connIDs: string[]
|
|
213
|
+
/** When the latest report came. */
|
|
214
|
+
readonly at: number
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Shared session state per user, keyed by (sessionID, connID) and merged on read (a Redis hash per user). */
|
|
218
|
+
export interface Catalog {
|
|
219
|
+
/** Records a connection's report; `ttlMs` lets reports of a dead process expire. */
|
|
220
|
+
set(userID: string, sessionID: string, connID: string, value: CatalogValue, options?: { readonly ttlMs?: number }): Promise<void>
|
|
221
|
+
/** Removes one connection's report of a session, or (no connID) every report of it. */
|
|
222
|
+
delete(userID: string, sessionID: string, connID?: string): Promise<void>
|
|
223
|
+
/** Removes everything a connection reported (it went away). */
|
|
224
|
+
deleteConn(userID: string, connID: string): Promise<void>
|
|
225
|
+
get(userID: string, sessionID: string): Promise<CatalogSession | undefined>
|
|
226
|
+
/** The user's sessions, merged, by session id. */
|
|
227
|
+
list(userID: string): Promise<CatalogSession[]>
|
|
228
|
+
/** The raw reports of the user (unmerged). */
|
|
229
|
+
entries(userID: string): Promise<CatalogEntry[]>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- Bundle -----------------------------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
export interface Backends {
|
|
235
|
+
readonly state: StateStore
|
|
236
|
+
readonly bus: Bus
|
|
237
|
+
readonly events: EventLog
|
|
238
|
+
readonly counters: Counters
|
|
239
|
+
readonly locks: Locks
|
|
240
|
+
readonly catalog: Catalog
|
|
241
|
+
readonly kv: Kv
|
|
242
|
+
/** Closes the connections a networked bundle opened (cluster mode); the hub calls it on stop. */
|
|
243
|
+
close?(): Promise<void>
|
|
244
|
+
}
|