@nanobpm/agentic 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/blackboard/family.d.ts +40 -0
- package/dist/blackboard/family.js +151 -0
- package/dist/blackboard/index.d.ts +21 -0
- package/dist/blackboard/index.js +19 -0
- package/dist/blackboard/schema.d.ts +30 -0
- package/dist/blackboard/schema.js +42 -0
- package/dist/blackboard/store.d.ts +138 -0
- package/dist/blackboard/store.js +216 -0
- package/dist/blackboard/test-db.d.ts +5 -0
- package/dist/blackboard/test-db.js +42 -0
- package/dist/channel/auth.d.ts +41 -0
- package/dist/channel/auth.js +67 -0
- package/dist/channel/clock.d.ts +11 -0
- package/dist/channel/clock.js +4 -0
- package/dist/channel/connection.d.ts +75 -0
- package/dist/channel/connection.js +14 -0
- package/dist/channel/dispatch.d.ts +46 -0
- package/dist/channel/dispatch.js +86 -0
- package/dist/channel/hub.d.ts +78 -0
- package/dist/channel/hub.js +157 -0
- package/dist/channel/index.d.ts +27 -0
- package/dist/channel/index.js +20 -0
- package/dist/channel/registry.d.ts +68 -0
- package/dist/channel/registry.js +84 -0
- package/dist/channel/ws-transport.d.ts +23 -0
- package/dist/channel/ws-transport.js +178 -0
- package/dist/cockpit/boot.d.ts +68 -0
- package/dist/cockpit/boot.js +202 -0
- package/dist/cockpit/fake-dom.d.ts +37 -0
- package/dist/cockpit/fake-dom.js +73 -0
- package/dist/cockpit/index.d.ts +27 -0
- package/dist/cockpit/index.js +27 -0
- package/dist/cockpit/relay-client.d.ts +52 -0
- package/dist/cockpit/relay-client.js +192 -0
- package/dist/cockpit/render.d.ts +58 -0
- package/dist/cockpit/render.js +122 -0
- package/dist/cockpit/terminal-session.d.ts +95 -0
- package/dist/cockpit/terminal-session.js +123 -0
- package/dist/cockpit/view.d.ts +79 -0
- package/dist/cockpit/view.js +58 -0
- package/dist/demand/c8-rest.d.ts +77 -0
- package/dist/demand/c8-rest.js +123 -0
- package/dist/demand/index.d.ts +24 -0
- package/dist/demand/index.js +24 -0
- package/dist/demand/model.d.ts +68 -0
- package/dist/demand/model.js +118 -0
- package/dist/demand/taskdef.d.ts +40 -0
- package/dist/demand/taskdef.js +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/presence/family.d.ts +40 -0
- package/dist/presence/family.js +166 -0
- package/dist/presence/index.d.ts +19 -0
- package/dist/presence/index.js +17 -0
- package/dist/presence/schema.d.ts +20 -0
- package/dist/presence/schema.js +32 -0
- package/dist/presence/store.d.ts +130 -0
- package/dist/presence/store.js +191 -0
- package/dist/presence/test-db.d.ts +5 -0
- package/dist/presence/test-db.js +42 -0
- package/dist/protocol/conformance/frames.d.ts +24 -0
- package/dist/protocol/conformance/frames.js +116 -0
- package/dist/protocol/conformance/index.d.ts +13 -0
- package/dist/protocol/conformance/index.js +13 -0
- package/dist/protocol/conformance/malformed.d.ts +14 -0
- package/dist/protocol/conformance/malformed.js +44 -0
- package/dist/protocol/conformance/tokens.d.ts +19 -0
- package/dist/protocol/conformance/tokens.js +49 -0
- package/dist/protocol/conformance/vocab.d.ts +23 -0
- package/dist/protocol/conformance/vocab.js +97 -0
- package/dist/protocol/families.d.ts +32 -0
- package/dist/protocol/families.js +45 -0
- package/dist/protocol/frame.d.ts +45 -0
- package/dist/protocol/frame.js +114 -0
- package/dist/protocol/hex.d.ts +7 -0
- package/dist/protocol/hex.js +26 -0
- package/dist/protocol/index.d.ts +23 -0
- package/dist/protocol/index.js +23 -0
- package/dist/protocol/lanes.d.ts +36 -0
- package/dist/protocol/lanes.js +40 -0
- package/dist/protocol/payloads.d.ts +64 -0
- package/dist/protocol/payloads.js +122 -0
- package/dist/protocol/token.d.ts +34 -0
- package/dist/protocol/token.js +81 -0
- package/dist/protocol/vocab/schema.d.ts +51 -0
- package/dist/protocol/vocab/schema.js +218 -0
- package/dist/relay/incarnation.d.ts +17 -0
- package/dist/relay/incarnation.js +50 -0
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +22 -0
- package/dist/relay/relay-family.d.ts +68 -0
- package/dist/relay/relay-family.js +272 -0
- package/dist/relay/ring.d.ts +49 -0
- package/dist/relay/ring.js +105 -0
- package/dist/relay/scheduler.d.ts +72 -0
- package/dist/relay/scheduler.js +180 -0
- package/dist/relay/validate.d.ts +29 -0
- package/dist/relay/validate.js +39 -0
- package/dist/transcript/index.d.ts +18 -0
- package/dist/transcript/index.js +17 -0
- package/dist/transcript/schema.d.ts +32 -0
- package/dist/transcript/schema.js +48 -0
- package/dist/transcript/store.d.ts +192 -0
- package/dist/transcript/store.js +347 -0
- package/dist/transcript/test-db.d.ts +5 -0
- package/dist/transcript/test-db.js +41 -0
- package/dist/vocab/core-vocab.d.ts +26 -0
- package/dist/vocab/core-vocab.js +67 -0
- package/dist/vocab/diversity.d.ts +78 -0
- package/dist/vocab/diversity.js +89 -0
- package/dist/vocab/index.d.ts +22 -0
- package/dist/vocab/index.js +22 -0
- package/dist/vocab/merge.d.ts +9 -0
- package/dist/vocab/merge.js +104 -0
- package/dist/vocab/requires.d.ts +49 -0
- package/dist/vocab/requires.js +107 -0
- package/dist/vocab/resolver.d.ts +62 -0
- package/dist/vocab/resolver.js +149 -0
- package/dist/vocab/serve.d.ts +39 -0
- package/dist/vocab/serve.js +36 -0
- package/package.json +108 -0
- package/page/cockpit.css +114 -0
- package/page/cockpit.page.json +33 -0
- package/page/embed.html +40 -0
- package/page/mount.js +78 -0
- package/page/standalone.html +43 -0
- package/src/blackboard/family.test.ts +280 -0
- package/src/blackboard/family.ts +208 -0
- package/src/blackboard/index.ts +42 -0
- package/src/blackboard/schema.test.ts +60 -0
- package/src/blackboard/schema.ts +44 -0
- package/src/blackboard/store.test.ts +189 -0
- package/src/blackboard/store.ts +331 -0
- package/src/blackboard/test-db.ts +47 -0
- package/src/channel/auth.test.ts +64 -0
- package/src/channel/auth.ts +101 -0
- package/src/channel/clock.ts +14 -0
- package/src/channel/connection.ts +77 -0
- package/src/channel/dispatch.test.ts +83 -0
- package/src/channel/dispatch.ts +102 -0
- package/src/channel/hub.test.ts +335 -0
- package/src/channel/hub.ts +222 -0
- package/src/channel/index.ts +55 -0
- package/src/channel/registry.test.ts +73 -0
- package/src/channel/registry.ts +137 -0
- package/src/channel/ws-transport.test.ts +234 -0
- package/src/channel/ws-transport.ts +212 -0
- package/src/cockpit/boot.test.ts +374 -0
- package/src/cockpit/boot.ts +280 -0
- package/src/cockpit/fake-dom.ts +90 -0
- package/src/cockpit/index.ts +63 -0
- package/src/cockpit/relay-client.test.ts +359 -0
- package/src/cockpit/relay-client.ts +234 -0
- package/src/cockpit/render.test.ts +149 -0
- package/src/cockpit/render.ts +194 -0
- package/src/cockpit/terminal-session.test.ts +252 -0
- package/src/cockpit/terminal-session.ts +194 -0
- package/src/cockpit/view.test.ts +117 -0
- package/src/cockpit/view.ts +140 -0
- package/src/demand/c8-rest.test.ts +140 -0
- package/src/demand/c8-rest.ts +167 -0
- package/src/demand/index.ts +42 -0
- package/src/demand/model.test.ts +197 -0
- package/src/demand/model.ts +183 -0
- package/src/demand/taskdef.test.ts +85 -0
- package/src/demand/taskdef.ts +78 -0
- package/src/index.ts +17 -0
- package/src/presence/family.test.ts +252 -0
- package/src/presence/family.ts +205 -0
- package/src/presence/index.ts +26 -0
- package/src/presence/schema.test.ts +53 -0
- package/src/presence/schema.ts +34 -0
- package/src/presence/store.test.ts +190 -0
- package/src/presence/store.ts +287 -0
- package/src/presence/test-db.test.ts +57 -0
- package/src/presence/test-db.ts +47 -0
- package/src/protocol/conformance/corpus.test.ts +66 -0
- package/src/protocol/conformance/frames.ts +142 -0
- package/src/protocol/conformance/index.ts +29 -0
- package/src/protocol/conformance/malformed.ts +59 -0
- package/src/protocol/conformance/tokens.ts +70 -0
- package/src/protocol/conformance/vocab.ts +122 -0
- package/src/protocol/families.ts +54 -0
- package/src/protocol/frame.test.ts +116 -0
- package/src/protocol/frame.ts +171 -0
- package/src/protocol/hex.ts +28 -0
- package/src/protocol/index.ts +84 -0
- package/src/protocol/lanes.test.ts +82 -0
- package/src/protocol/lanes.ts +54 -0
- package/src/protocol/payloads.test.ts +91 -0
- package/src/protocol/payloads.ts +201 -0
- package/src/protocol/token.test.ts +57 -0
- package/src/protocol/token.ts +123 -0
- package/src/protocol/vocab/schema.test.ts +67 -0
- package/src/protocol/vocab/schema.ts +281 -0
- package/src/relay/incarnation.test.ts +53 -0
- package/src/relay/incarnation.ts +54 -0
- package/src/relay/index.ts +34 -0
- package/src/relay/integration.test.ts +135 -0
- package/src/relay/relay-family.test.ts +236 -0
- package/src/relay/relay-family.ts +336 -0
- package/src/relay/ring.test.ts +138 -0
- package/src/relay/ring.ts +136 -0
- package/src/relay/scheduler.test.ts +233 -0
- package/src/relay/scheduler.ts +208 -0
- package/src/relay/validate.test.ts +43 -0
- package/src/relay/validate.ts +44 -0
- package/src/transcript/index.ts +33 -0
- package/src/transcript/integration.test.ts +108 -0
- package/src/transcript/schema.test.ts +69 -0
- package/src/transcript/schema.ts +51 -0
- package/src/transcript/store.test.ts +285 -0
- package/src/transcript/store.ts +530 -0
- package/src/transcript/test-db.ts +46 -0
- package/src/vocab/core-vocab.test.ts +34 -0
- package/src/vocab/core-vocab.ts +88 -0
- package/src/vocab/diversity.test.ts +153 -0
- package/src/vocab/diversity.ts +169 -0
- package/src/vocab/index.ts +55 -0
- package/src/vocab/merge.test.ts +73 -0
- package/src/vocab/merge.ts +117 -0
- package/src/vocab/requires.test.ts +69 -0
- package/src/vocab/requires.ts +155 -0
- package/src/vocab/resolver.test.ts +118 -0
- package/src/vocab/resolver.ts +187 -0
- package/src/vocab/serve.test.ts +64 -0
- package/src/vocab/serve.ts +66 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AgenticHub } from "../channel/index.ts";
|
|
2
|
+
import type { PresenceRow, PresenceStore } from "./store.ts";
|
|
3
|
+
export interface PresenceFamilyOptions {
|
|
4
|
+
/**
|
|
5
|
+
* How often to age out stale presence rows, in ms. Default: a third of the
|
|
6
|
+
* store's TTL. Pass 0 to disable the internal timer (tests call
|
|
7
|
+
* {@link PresenceFamilyHandle.sweepNow} explicitly). The sweep reads "now"
|
|
8
|
+
* from the store's own clock, so liveness time has a single source of truth.
|
|
9
|
+
*/
|
|
10
|
+
sweepIntervalMs?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Notified of a fault this module handles while keeping the connection: a
|
|
13
|
+
* malformed presence payload ({@link PresencePayloadError}), a rejected
|
|
14
|
+
* ownership takeover ({@link PresenceOwnershipError}), or a presence-sweep
|
|
15
|
+
* error. Other handler exceptions are not routed here — they propagate to
|
|
16
|
+
* {@link AgenticHub} and surface via the hub's own error handling.
|
|
17
|
+
*/
|
|
18
|
+
onError?: (err: unknown, connectionId?: string) => void;
|
|
19
|
+
}
|
|
20
|
+
/** Handle to the attached presence family — drives/stops the presence sweep. */
|
|
21
|
+
export interface PresenceFamilyHandle {
|
|
22
|
+
/** Age out stale presence rows now and return the removed rows. */
|
|
23
|
+
sweepNow(): PresenceRow[];
|
|
24
|
+
/** Stop the presence sweep timer. */
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
/** A malformed presence payload rejected before it touches the store. */
|
|
28
|
+
export declare class PresencePayloadError extends Error {
|
|
29
|
+
readonly family: string;
|
|
30
|
+
constructor(family: string, detail: string);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Attach the presence family (`register`/`heartbeat`/`deregister`) to `hub`,
|
|
34
|
+
* backed by `store`. Registers three handlers via the S1 seam and schedules the
|
|
35
|
+
* presence-TTL sweep. Returns a handle to drive/stop the sweep.
|
|
36
|
+
*
|
|
37
|
+
* @throws DuplicateFamilyHandlerError if one of the three families already has
|
|
38
|
+
* a handler on this hub (one family, one owning module).
|
|
39
|
+
*/
|
|
40
|
+
export declare function attachPresenceFamily(hub: AgenticHub, store: PresenceStore, options?: PresenceFamilyOptions): PresenceFamilyHandle;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The presence family module — S2's self-contained attachment to the S1 hub.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the `register`, `heartbeat` and `deregister` message
|
|
5
|
+
* families. It attaches through the hub's canonical
|
|
6
|
+
* {@link AgenticHub.registerFamilyHandler} seam — it does NOT touch a shared
|
|
7
|
+
* frame→family dispatch switch — so it composes with the other wave-2 family
|
|
8
|
+
* modules (S5 relay, S7 blackboard) with no shared edit.
|
|
9
|
+
*
|
|
10
|
+
* On `register` it writes a durable presence row (via {@link PresenceStore}) and
|
|
11
|
+
* mirrors instance+capability onto S1's in-memory connection registry
|
|
12
|
+
* (`ctx.registry.setPresence`). `heartbeat` refreshes the row's liveness;
|
|
13
|
+
* `deregister` removes it. Rows the fleet stops heartbeating age out on the
|
|
14
|
+
* presence TTL via the sweep this module schedules.
|
|
15
|
+
*/
|
|
16
|
+
import { validatePayload } from "../protocol/index.js";
|
|
17
|
+
import { PresenceOwnershipError } from "./store.js";
|
|
18
|
+
/** A malformed presence payload rejected before it touches the store. */
|
|
19
|
+
export class PresencePayloadError extends Error {
|
|
20
|
+
family;
|
|
21
|
+
constructor(family, detail) {
|
|
22
|
+
super(`invalid ${family} payload: ${detail}`);
|
|
23
|
+
this.name = "PresencePayloadError";
|
|
24
|
+
this.family = family;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function readString(record, key) {
|
|
31
|
+
const value = record[key];
|
|
32
|
+
return typeof value === "string" ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
function readNumber(record, key) {
|
|
35
|
+
const value = record[key];
|
|
36
|
+
return typeof value === "number" ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
/** Extract the enrolment capability from a validated `register` payload. */
|
|
39
|
+
function readCapability(record) {
|
|
40
|
+
const raw = record.capability;
|
|
41
|
+
if (!isPlainObject(raw))
|
|
42
|
+
return {};
|
|
43
|
+
const cap = {};
|
|
44
|
+
const cognition = readString(raw, "cognition");
|
|
45
|
+
if (cognition !== undefined)
|
|
46
|
+
cap.cognition = cognition;
|
|
47
|
+
const weight = readNumber(raw, "weight");
|
|
48
|
+
if (weight !== undefined)
|
|
49
|
+
cap.weight = weight;
|
|
50
|
+
const family = readString(raw, "family");
|
|
51
|
+
if (family !== undefined)
|
|
52
|
+
cap.family = family;
|
|
53
|
+
const host = readString(raw, "host");
|
|
54
|
+
if (host !== undefined)
|
|
55
|
+
cap.host = host;
|
|
56
|
+
return cap;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Attach the presence family (`register`/`heartbeat`/`deregister`) to `hub`,
|
|
60
|
+
* backed by `store`. Registers three handlers via the S1 seam and schedules the
|
|
61
|
+
* presence-TTL sweep. Returns a handle to drive/stop the sweep.
|
|
62
|
+
*
|
|
63
|
+
* @throws DuplicateFamilyHandlerError if one of the three families already has
|
|
64
|
+
* a handler on this hub (one family, one owning module).
|
|
65
|
+
*/
|
|
66
|
+
export function attachPresenceFamily(hub, store, options = {}) {
|
|
67
|
+
store.ensureSchema();
|
|
68
|
+
const onError = options.onError ?? (() => { });
|
|
69
|
+
const reject = (family, connectionId, detail) => {
|
|
70
|
+
onError(new PresencePayloadError(family, detail), connectionId);
|
|
71
|
+
};
|
|
72
|
+
hub.registerFamilyHandler("register", (frame, ctx) => {
|
|
73
|
+
const payload = frame.payload;
|
|
74
|
+
const result = validatePayload("register", payload);
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
reject("register", ctx.id, result.errors.map((e) => e.message).join("; "));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!isPlainObject(payload)) {
|
|
80
|
+
reject("register", ctx.id, "not an object");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const instance = readString(payload, "instance");
|
|
84
|
+
if (instance === undefined) {
|
|
85
|
+
reject("register", ctx.id, "missing instance");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const capability = readCapability(payload);
|
|
89
|
+
try {
|
|
90
|
+
store.register({ instance, connectionId: ctx.id, identity: ctx.identity, capability });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
// An instance owned by another identity must not be hijacked: surface the
|
|
94
|
+
// rejection and leave S1's in-memory registry untouched.
|
|
95
|
+
if (err instanceof PresenceOwnershipError) {
|
|
96
|
+
onError(err, ctx.id);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
// Mirror the enrolment onto S1's in-memory connection registry so a live
|
|
102
|
+
// connection carries its instance+capability without a DB read.
|
|
103
|
+
ctx.registry.setPresence(ctx.id, { instance, capability });
|
|
104
|
+
});
|
|
105
|
+
hub.registerFamilyHandler("heartbeat", (frame, ctx) => {
|
|
106
|
+
const payload = frame.payload;
|
|
107
|
+
const result = validatePayload("heartbeat", payload);
|
|
108
|
+
if (!result.ok) {
|
|
109
|
+
reject("heartbeat", ctx.id, result.errors.map((e) => e.message).join("; "));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!isPlainObject(payload)) {
|
|
113
|
+
reject("heartbeat", ctx.id, "not an object");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const instance = readString(payload, "instance");
|
|
117
|
+
if (instance === undefined) {
|
|
118
|
+
reject("heartbeat", ctx.id, "missing instance");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
store.heartbeat(instance, ctx.identity);
|
|
122
|
+
});
|
|
123
|
+
hub.registerFamilyHandler("deregister", (frame, ctx) => {
|
|
124
|
+
const payload = frame.payload;
|
|
125
|
+
const result = validatePayload("deregister", payload);
|
|
126
|
+
if (!result.ok) {
|
|
127
|
+
reject("deregister", ctx.id, result.errors.map((e) => e.message).join("; "));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!isPlainObject(payload)) {
|
|
131
|
+
reject("deregister", ctx.id, "not an object");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const instance = readString(payload, "instance");
|
|
135
|
+
if (instance === undefined) {
|
|
136
|
+
reject("deregister", ctx.id, "missing instance");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
store.deregister(instance, ctx.identity);
|
|
140
|
+
});
|
|
141
|
+
const sweepNow = () => {
|
|
142
|
+
try {
|
|
143
|
+
return store.sweep();
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
onError(err);
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const interval = options.sweepIntervalMs ?? Math.max(1, Math.floor(store.ttlMs / 3));
|
|
151
|
+
let timer;
|
|
152
|
+
if (interval > 0) {
|
|
153
|
+
timer = setInterval(sweepNow, interval);
|
|
154
|
+
// Do not keep the process alive solely for the presence sweep.
|
|
155
|
+
timer.unref?.();
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
sweepNow,
|
|
159
|
+
stop() {
|
|
160
|
+
if (timer !== undefined) {
|
|
161
|
+
clearInterval(timer);
|
|
162
|
+
timer = undefined;
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic-presence — the presence & registry family for the Nano
|
|
3
|
+
* agentic protocol (ADR 0056, slice S2).
|
|
4
|
+
*
|
|
5
|
+
* Owns the `register` / `heartbeat` / `deregister` message families: a durable
|
|
6
|
+
* presence registry over the app DataLayer/SQLite ({@link PresenceStore}) plus
|
|
7
|
+
* a self-contained family module ({@link attachPresenceFamily}) that attaches to
|
|
8
|
+
* the S1 hub through its `registerFamilyHandler(family, handler)` seam — never a
|
|
9
|
+
* shared dispatch switch. S1 owns connection liveness; this slice layers durable
|
|
10
|
+
* presence rows with their own heartbeat-refreshed TTL on top.
|
|
11
|
+
*
|
|
12
|
+
* The wire contract lives in `@nanobpm/agentic-protocol`; the hub in
|
|
13
|
+
* `@nanobpm/agentic-channel`. This package builds on both.
|
|
14
|
+
*/
|
|
15
|
+
export { PresenceOwnershipError, PresenceStore } from "./store.ts";
|
|
16
|
+
export type { PresenceRow, PresenceStoreOptions, RegisterInput, SqliteDb, } from "./store.ts";
|
|
17
|
+
export { attachPresenceFamily, PresencePayloadError } from "./family.ts";
|
|
18
|
+
export type { PresenceFamilyOptions, PresenceFamilyHandle } from "./family.ts";
|
|
19
|
+
export { PRESENCE_TABLE, PRESENCE_SCHEMA_SQL } from "./schema.ts";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic-presence — the presence & registry family for the Nano
|
|
3
|
+
* agentic protocol (ADR 0056, slice S2).
|
|
4
|
+
*
|
|
5
|
+
* Owns the `register` / `heartbeat` / `deregister` message families: a durable
|
|
6
|
+
* presence registry over the app DataLayer/SQLite ({@link PresenceStore}) plus
|
|
7
|
+
* a self-contained family module ({@link attachPresenceFamily}) that attaches to
|
|
8
|
+
* the S1 hub through its `registerFamilyHandler(family, handler)` seam — never a
|
|
9
|
+
* shared dispatch switch. S1 owns connection liveness; this slice layers durable
|
|
10
|
+
* presence rows with their own heartbeat-refreshed TTL on top.
|
|
11
|
+
*
|
|
12
|
+
* The wire contract lives in `@nanobpm/agentic-protocol`; the hub in
|
|
13
|
+
* `@nanobpm/agentic-channel`. This package builds on both.
|
|
14
|
+
*/
|
|
15
|
+
export { PresenceOwnershipError, PresenceStore } from "./store.js";
|
|
16
|
+
export { attachPresenceFamily, PresencePayloadError } from "./family.js";
|
|
17
|
+
export { PRESENCE_TABLE, PRESENCE_SCHEMA_SQL } from "./schema.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical presence-registry schema.
|
|
3
|
+
*
|
|
4
|
+
* The DDL here is the single source of truth the {@link PresenceStore} applies
|
|
5
|
+
* through {@link PresenceStore.ensureSchema}. The very same statements are
|
|
6
|
+
* mirrored in the app-boot migration `db/migrations/001_agentic_presence.sql`
|
|
7
|
+
* (applied by the DataLayer migration runner). To keep those two application
|
|
8
|
+
* paths from drifting, `schema.test.ts` normalises both and asserts they are
|
|
9
|
+
* statement-for-statement identical — divergence is a red test, not a silent
|
|
10
|
+
* production/boot mismatch.
|
|
11
|
+
*/
|
|
12
|
+
/** The presence & registry table name. */
|
|
13
|
+
export declare const PRESENCE_TABLE = "agentic_presence";
|
|
14
|
+
/**
|
|
15
|
+
* The canonical presence-registry DDL. Forward-only and additive; every column
|
|
16
|
+
* added here must also be added to the boot migration (the drift guard enforces
|
|
17
|
+
* it). Capability (cognition/weight/family/host) is stored as an ENROLMENT
|
|
18
|
+
* attribute — never a routing token.
|
|
19
|
+
*/
|
|
20
|
+
export declare const PRESENCE_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS agentic_presence (\n instance TEXT PRIMARY KEY,\n connection_id TEXT NOT NULL,\n identity TEXT NOT NULL,\n cognition TEXT,\n weight REAL,\n family TEXT,\n host TEXT,\n registered_at TEXT NOT NULL,\n last_seen INTEGER NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_agentic_presence_last_seen ON agentic_presence (last_seen);\nCREATE INDEX IF NOT EXISTS idx_agentic_presence_connection ON agentic_presence (connection_id);";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical presence-registry schema.
|
|
3
|
+
*
|
|
4
|
+
* The DDL here is the single source of truth the {@link PresenceStore} applies
|
|
5
|
+
* through {@link PresenceStore.ensureSchema}. The very same statements are
|
|
6
|
+
* mirrored in the app-boot migration `db/migrations/001_agentic_presence.sql`
|
|
7
|
+
* (applied by the DataLayer migration runner). To keep those two application
|
|
8
|
+
* paths from drifting, `schema.test.ts` normalises both and asserts they are
|
|
9
|
+
* statement-for-statement identical — divergence is a red test, not a silent
|
|
10
|
+
* production/boot mismatch.
|
|
11
|
+
*/
|
|
12
|
+
/** The presence & registry table name. */
|
|
13
|
+
export const PRESENCE_TABLE = "agentic_presence";
|
|
14
|
+
/**
|
|
15
|
+
* The canonical presence-registry DDL. Forward-only and additive; every column
|
|
16
|
+
* added here must also be added to the boot migration (the drift guard enforces
|
|
17
|
+
* it). Capability (cognition/weight/family/host) is stored as an ENROLMENT
|
|
18
|
+
* attribute — never a routing token.
|
|
19
|
+
*/
|
|
20
|
+
export const PRESENCE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${PRESENCE_TABLE} (
|
|
21
|
+
instance TEXT PRIMARY KEY,
|
|
22
|
+
connection_id TEXT NOT NULL,
|
|
23
|
+
identity TEXT NOT NULL,
|
|
24
|
+
cognition TEXT,
|
|
25
|
+
weight REAL,
|
|
26
|
+
family TEXT,
|
|
27
|
+
host TEXT,
|
|
28
|
+
registered_at TEXT NOT NULL,
|
|
29
|
+
last_seen INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_${PRESENCE_TABLE}_last_seen ON ${PRESENCE_TABLE} (last_seen);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_${PRESENCE_TABLE}_connection ON ${PRESENCE_TABLE} (connection_id);`;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The presence & registry store — S2's durable layer over the app DataLayer.
|
|
3
|
+
*
|
|
4
|
+
* S1 owns *connection* liveness in-memory (touch-on-frame + a TTL sweep that
|
|
5
|
+
* closes silent sockets). S2 layers a durable *presence* registry on top: one
|
|
6
|
+
* row per registered worker instance, carrying its declared capability
|
|
7
|
+
* (cognition/weight/family/host — an enrolment attribute, never a routing
|
|
8
|
+
* token), the connection it registered on, and its own `last_seen` liveness
|
|
9
|
+
* refreshed by heartbeats. Rows age out on the presence TTL via {@link sweep}.
|
|
10
|
+
*
|
|
11
|
+
* The store speaks only the tiny synchronous SQLite subset the runtime exposes
|
|
12
|
+
* ({@link SqliteDb}), so it works against any app DataLayer source without
|
|
13
|
+
* pulling in the whole runtime.
|
|
14
|
+
*/
|
|
15
|
+
import type { Capability } from "../protocol/index.ts";
|
|
16
|
+
import type { Clock } from "../channel/index.ts";
|
|
17
|
+
/**
|
|
18
|
+
* The minimal synchronous SQLite handle the store needs — structurally the same
|
|
19
|
+
* surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local
|
|
20
|
+
* so the store depends on a shape, not on the runtime package.
|
|
21
|
+
*/
|
|
22
|
+
export interface SqliteDb {
|
|
23
|
+
/** Execute one or more statements with no result (DDL, migrations). */
|
|
24
|
+
exec(sql: string): void;
|
|
25
|
+
/** Run a parameterised statement, returning the changed-row count. */
|
|
26
|
+
run(sql: string, params?: unknown[]): {
|
|
27
|
+
changes: number;
|
|
28
|
+
lastInsertRowid: number | bigint;
|
|
29
|
+
};
|
|
30
|
+
/** Run a parameterised query, returning all rows as plain objects. */
|
|
31
|
+
all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
|
|
32
|
+
}
|
|
33
|
+
/** A durable presence row for one registered worker instance. */
|
|
34
|
+
export interface PresenceRow {
|
|
35
|
+
/** The worker instance id (`register.instance`) — the primary key. */
|
|
36
|
+
readonly instance: string;
|
|
37
|
+
/** The channel connection the instance last registered on. */
|
|
38
|
+
readonly connectionId: string;
|
|
39
|
+
/** The authenticated principal (ADR 0028 identity) of that connection. */
|
|
40
|
+
readonly identity: string;
|
|
41
|
+
/** The declared enrolment capability (never a routing token). */
|
|
42
|
+
readonly capability: Capability;
|
|
43
|
+
/** When the instance first registered, ISO-8601. */
|
|
44
|
+
readonly registeredAt: string;
|
|
45
|
+
/** Last liveness refresh (register/heartbeat), epoch ms. */
|
|
46
|
+
readonly lastSeen: number;
|
|
47
|
+
}
|
|
48
|
+
/** Input to {@link PresenceStore.register}. */
|
|
49
|
+
export interface RegisterInput {
|
|
50
|
+
readonly instance: string;
|
|
51
|
+
readonly connectionId: string;
|
|
52
|
+
readonly identity: string;
|
|
53
|
+
readonly capability: Capability;
|
|
54
|
+
}
|
|
55
|
+
export interface PresenceStoreOptions {
|
|
56
|
+
/** Presence liveness TTL in ms; a row unseen for longer ages out. Default 30000. */
|
|
57
|
+
ttlMs?: number;
|
|
58
|
+
/** Injectable clock for deterministic tests. Default {@link systemClock}. */
|
|
59
|
+
clock?: Clock;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Raised when a `register` would overwrite an instance already owned by a
|
|
63
|
+
* different authenticated identity. Presence rows are bound to the identity
|
|
64
|
+
* that first registered them, so one authenticated peer can never take over
|
|
65
|
+
* (or change the identity of) another peer's instance.
|
|
66
|
+
*/
|
|
67
|
+
export declare class PresenceOwnershipError extends Error {
|
|
68
|
+
readonly instance: string;
|
|
69
|
+
constructor(instance: string);
|
|
70
|
+
}
|
|
71
|
+
export declare class PresenceStore {
|
|
72
|
+
#private;
|
|
73
|
+
constructor(db: SqliteDb, options?: PresenceStoreOptions);
|
|
74
|
+
/** The presence liveness TTL in ms. */
|
|
75
|
+
get ttlMs(): number;
|
|
76
|
+
/**
|
|
77
|
+
* Apply the canonical presence DDL (idempotent). Callers that let the app
|
|
78
|
+
* DataLayer migration runner apply `db/migrations/001_agentic_presence.sql`
|
|
79
|
+
* do not need this — but the family module calls it so the store is usable
|
|
80
|
+
* against a bare source too. The DDL is identical to the migration (guarded).
|
|
81
|
+
*/
|
|
82
|
+
ensureSchema(): void;
|
|
83
|
+
/**
|
|
84
|
+
* Register (or re-register) an instance. A first registration stamps
|
|
85
|
+
* `registered_at`; a re-registration (e.g. after a reconnect on a new
|
|
86
|
+
* connection) keeps the original `registered_at` and refreshes everything
|
|
87
|
+
* else, including `last_seen`. Returns the stored row.
|
|
88
|
+
*
|
|
89
|
+
* Ownership is bound to the authenticated `identity` that first registered the
|
|
90
|
+
* instance: a re-register from the same identity (the reconnect case) is
|
|
91
|
+
* allowed, but one from a *different* identity is rejected with a
|
|
92
|
+
* {@link PresenceOwnershipError} and leaves the existing row untouched — no
|
|
93
|
+
* peer can take over another peer's instance or rewrite its identity.
|
|
94
|
+
*/
|
|
95
|
+
register(input: RegisterInput): PresenceRow;
|
|
96
|
+
/**
|
|
97
|
+
* Refresh an instance's liveness to now. Returns `true` if the instance was
|
|
98
|
+
* registered, `false` if there is no such row (a heartbeat before register).
|
|
99
|
+
* When `identity` is given, the refresh is scoped to the owning identity, so a
|
|
100
|
+
* heartbeat from a foreign identity is a silent no-op (and cannot probe for
|
|
101
|
+
* the existence of another peer's instance).
|
|
102
|
+
*/
|
|
103
|
+
heartbeat(instance: string, identity?: string): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Remove an instance's presence row. Returns `true` if a row was removed. When
|
|
106
|
+
* `identity` is given, the removal is scoped to the owning identity, so a
|
|
107
|
+
* deregister from a foreign identity is a silent no-op.
|
|
108
|
+
*/
|
|
109
|
+
deregister(instance: string, identity?: string): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Remove every presence row registered on a now-dead connection (e.g. one S1
|
|
112
|
+
* closed on its own liveness sweep). Returns the removed instance ids so the
|
|
113
|
+
* caller can react. Presence also ages out via {@link sweep}; this is the
|
|
114
|
+
* eager path when a disconnect is observed.
|
|
115
|
+
*/
|
|
116
|
+
removeByConnection(connectionId: string): string[];
|
|
117
|
+
/**
|
|
118
|
+
* Age out every presence row whose `last_seen` is older than the TTL and
|
|
119
|
+
* return the removed rows. `now` defaults to the clock; pass an explicit value
|
|
120
|
+
* for deterministic tests. Matches S1's liveness predicate (`now - lastSeen >
|
|
121
|
+
* ttl`).
|
|
122
|
+
*/
|
|
123
|
+
sweep(now?: number): PresenceRow[];
|
|
124
|
+
/** Look up a single instance's presence row. */
|
|
125
|
+
get(instance: string): PresenceRow | undefined;
|
|
126
|
+
/** Every presence row, ordered by first registration then instance id. */
|
|
127
|
+
list(): PresenceRow[];
|
|
128
|
+
/** Number of registered instances. */
|
|
129
|
+
count(): number;
|
|
130
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { systemClock } from "../channel/index.js";
|
|
2
|
+
import { PRESENCE_SCHEMA_SQL, PRESENCE_TABLE } from "./schema.js";
|
|
3
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
4
|
+
/**
|
|
5
|
+
* Raised when a `register` would overwrite an instance already owned by a
|
|
6
|
+
* different authenticated identity. Presence rows are bound to the identity
|
|
7
|
+
* that first registered them, so one authenticated peer can never take over
|
|
8
|
+
* (or change the identity of) another peer's instance.
|
|
9
|
+
*/
|
|
10
|
+
export class PresenceOwnershipError extends Error {
|
|
11
|
+
instance;
|
|
12
|
+
constructor(instance) {
|
|
13
|
+
super(`instance "${instance}" is registered to a different identity`);
|
|
14
|
+
this.name = "PresenceOwnershipError";
|
|
15
|
+
this.instance = instance;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function buildCapability(row) {
|
|
19
|
+
const cap = {};
|
|
20
|
+
if (row.cognition !== null)
|
|
21
|
+
cap.cognition = row.cognition;
|
|
22
|
+
if (row.weight !== null)
|
|
23
|
+
cap.weight = row.weight;
|
|
24
|
+
if (row.family !== null)
|
|
25
|
+
cap.family = row.family;
|
|
26
|
+
if (row.host !== null)
|
|
27
|
+
cap.host = row.host;
|
|
28
|
+
return cap;
|
|
29
|
+
}
|
|
30
|
+
function toRow(row) {
|
|
31
|
+
return {
|
|
32
|
+
instance: row.instance,
|
|
33
|
+
connectionId: row.connection_id,
|
|
34
|
+
identity: row.identity,
|
|
35
|
+
capability: buildCapability(row),
|
|
36
|
+
registeredAt: row.registered_at,
|
|
37
|
+
lastSeen: row.last_seen,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export class PresenceStore {
|
|
41
|
+
#db;
|
|
42
|
+
#ttlMs;
|
|
43
|
+
#clock;
|
|
44
|
+
constructor(db, options = {}) {
|
|
45
|
+
this.#db = db;
|
|
46
|
+
this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
47
|
+
this.#clock = options.clock ?? systemClock;
|
|
48
|
+
}
|
|
49
|
+
/** The presence liveness TTL in ms. */
|
|
50
|
+
get ttlMs() {
|
|
51
|
+
return this.#ttlMs;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Apply the canonical presence DDL (idempotent). Callers that let the app
|
|
55
|
+
* DataLayer migration runner apply `db/migrations/001_agentic_presence.sql`
|
|
56
|
+
* do not need this — but the family module calls it so the store is usable
|
|
57
|
+
* against a bare source too. The DDL is identical to the migration (guarded).
|
|
58
|
+
*/
|
|
59
|
+
ensureSchema() {
|
|
60
|
+
this.#db.exec(PRESENCE_SCHEMA_SQL);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Register (or re-register) an instance. A first registration stamps
|
|
64
|
+
* `registered_at`; a re-registration (e.g. after a reconnect on a new
|
|
65
|
+
* connection) keeps the original `registered_at` and refreshes everything
|
|
66
|
+
* else, including `last_seen`. Returns the stored row.
|
|
67
|
+
*
|
|
68
|
+
* Ownership is bound to the authenticated `identity` that first registered the
|
|
69
|
+
* instance: a re-register from the same identity (the reconnect case) is
|
|
70
|
+
* allowed, but one from a *different* identity is rejected with a
|
|
71
|
+
* {@link PresenceOwnershipError} and leaves the existing row untouched — no
|
|
72
|
+
* peer can take over another peer's instance or rewrite its identity.
|
|
73
|
+
*/
|
|
74
|
+
register(input) {
|
|
75
|
+
const now = this.#clock.now();
|
|
76
|
+
const cap = input.capability;
|
|
77
|
+
this.#db.run(`INSERT INTO ${PRESENCE_TABLE}
|
|
78
|
+
(instance, connection_id, identity, cognition, weight, family, host, registered_at, last_seen)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
|
+
ON CONFLICT(instance) DO UPDATE SET
|
|
81
|
+
connection_id = excluded.connection_id,
|
|
82
|
+
identity = excluded.identity,
|
|
83
|
+
cognition = excluded.cognition,
|
|
84
|
+
weight = excluded.weight,
|
|
85
|
+
family = excluded.family,
|
|
86
|
+
host = excluded.host,
|
|
87
|
+
last_seen = excluded.last_seen
|
|
88
|
+
WHERE ${PRESENCE_TABLE}.identity = excluded.identity`, [
|
|
89
|
+
input.instance,
|
|
90
|
+
input.connectionId,
|
|
91
|
+
input.identity,
|
|
92
|
+
cap.cognition ?? null,
|
|
93
|
+
cap.weight ?? null,
|
|
94
|
+
cap.family ?? null,
|
|
95
|
+
cap.host ?? null,
|
|
96
|
+
new Date(now).toISOString(),
|
|
97
|
+
now,
|
|
98
|
+
]);
|
|
99
|
+
// Enforce ownership on the *persisted* row rather than the driver's
|
|
100
|
+
// changed-row count. The identity guard in the UPSERT's `WHERE` leaves a
|
|
101
|
+
// foreign-owned row untouched, so after the statement the stored identity
|
|
102
|
+
// is authoritative: if it differs from the caller's, this register was a
|
|
103
|
+
// rejected takeover. Reading it back keeps the invariant independent of how
|
|
104
|
+
// SQLite counts UPSERT changes (which counts WHERE-matched rows, not just
|
|
105
|
+
// value-changing ones), so a same-identity no-op re-register never trips it.
|
|
106
|
+
const row = this.get(input.instance);
|
|
107
|
+
if (row === undefined) {
|
|
108
|
+
throw new Error(`presence row vanished immediately after register: ${input.instance}`);
|
|
109
|
+
}
|
|
110
|
+
if (row.identity !== input.identity) {
|
|
111
|
+
throw new PresenceOwnershipError(input.instance);
|
|
112
|
+
}
|
|
113
|
+
return row;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Refresh an instance's liveness to now. Returns `true` if the instance was
|
|
117
|
+
* registered, `false` if there is no such row (a heartbeat before register).
|
|
118
|
+
* When `identity` is given, the refresh is scoped to the owning identity, so a
|
|
119
|
+
* heartbeat from a foreign identity is a silent no-op (and cannot probe for
|
|
120
|
+
* the existence of another peer's instance).
|
|
121
|
+
*/
|
|
122
|
+
heartbeat(instance, identity) {
|
|
123
|
+
const { changes } = identity === undefined
|
|
124
|
+
? this.#db.run(`UPDATE ${PRESENCE_TABLE} SET last_seen = ? WHERE instance = ?`, [this.#clock.now(), instance])
|
|
125
|
+
: this.#db.run(`UPDATE ${PRESENCE_TABLE} SET last_seen = ? WHERE instance = ? AND identity = ?`, [
|
|
126
|
+
this.#clock.now(),
|
|
127
|
+
instance,
|
|
128
|
+
identity,
|
|
129
|
+
]);
|
|
130
|
+
return changes > 0;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Remove an instance's presence row. Returns `true` if a row was removed. When
|
|
134
|
+
* `identity` is given, the removal is scoped to the owning identity, so a
|
|
135
|
+
* deregister from a foreign identity is a silent no-op.
|
|
136
|
+
*/
|
|
137
|
+
deregister(instance, identity) {
|
|
138
|
+
const { changes } = identity === undefined
|
|
139
|
+
? this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE instance = ?`, [instance])
|
|
140
|
+
: this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE instance = ? AND identity = ?`, [instance, identity]);
|
|
141
|
+
return changes > 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Remove every presence row registered on a now-dead connection (e.g. one S1
|
|
145
|
+
* closed on its own liveness sweep). Returns the removed instance ids so the
|
|
146
|
+
* caller can react. Presence also ages out via {@link sweep}; this is the
|
|
147
|
+
* eager path when a disconnect is observed.
|
|
148
|
+
*/
|
|
149
|
+
removeByConnection(connectionId) {
|
|
150
|
+
const removed = this.#db
|
|
151
|
+
.all(`SELECT instance FROM ${PRESENCE_TABLE} WHERE connection_id = ?`, [connectionId])
|
|
152
|
+
.map((r) => r.instance);
|
|
153
|
+
if (removed.length > 0) {
|
|
154
|
+
this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE connection_id = ?`, [connectionId]);
|
|
155
|
+
}
|
|
156
|
+
return removed;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Age out every presence row whose `last_seen` is older than the TTL and
|
|
160
|
+
* return the removed rows. `now` defaults to the clock; pass an explicit value
|
|
161
|
+
* for deterministic tests. Matches S1's liveness predicate (`now - lastSeen >
|
|
162
|
+
* ttl`).
|
|
163
|
+
*/
|
|
164
|
+
sweep(now = this.#clock.now()) {
|
|
165
|
+
const cutoff = now - this.#ttlMs;
|
|
166
|
+
const stale = this.#db
|
|
167
|
+
.all(`SELECT * FROM ${PRESENCE_TABLE} WHERE last_seen < ?`, [cutoff])
|
|
168
|
+
.map(toRow);
|
|
169
|
+
if (stale.length > 0) {
|
|
170
|
+
this.#db.run(`DELETE FROM ${PRESENCE_TABLE} WHERE last_seen < ?`, [cutoff]);
|
|
171
|
+
}
|
|
172
|
+
return stale;
|
|
173
|
+
}
|
|
174
|
+
/** Look up a single instance's presence row. */
|
|
175
|
+
get(instance) {
|
|
176
|
+
const rows = this.#db.all(`SELECT * FROM ${PRESENCE_TABLE} WHERE instance = ?`, [instance]);
|
|
177
|
+
const row = rows[0];
|
|
178
|
+
return row === undefined ? undefined : toRow(row);
|
|
179
|
+
}
|
|
180
|
+
/** Every presence row, ordered by first registration then instance id. */
|
|
181
|
+
list() {
|
|
182
|
+
return this.#db
|
|
183
|
+
.all(`SELECT * FROM ${PRESENCE_TABLE} ORDER BY registered_at, instance`)
|
|
184
|
+
.map(toRow);
|
|
185
|
+
}
|
|
186
|
+
/** Number of registered instances. */
|
|
187
|
+
count() {
|
|
188
|
+
const rows = this.#db.all(`SELECT COUNT(*) AS n FROM ${PRESENCE_TABLE}`);
|
|
189
|
+
return rows[0]?.n ?? 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
|
|
3
|
+
* database, mirroring the Node host adapter's `wrapNodeSqlite`. Kept out of the
|
|
4
|
+
* published build (see `tsconfig.build.json` exclude) — it exists solely so the
|
|
5
|
+
* store/family tests exercise the store against a real SQLite engine, not a mock.
|
|
6
|
+
*/
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
function toParams(params) {
|
|
9
|
+
return params.map((p) => {
|
|
10
|
+
if (p === null)
|
|
11
|
+
return null;
|
|
12
|
+
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
if (typeof p === "boolean")
|
|
16
|
+
return p ? 1 : 0;
|
|
17
|
+
// Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
|
|
18
|
+
// `undefined`) throw rather than coerce, so a test can never pass on a
|
|
19
|
+
// parameter production would reject.
|
|
20
|
+
throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function openTestDb() {
|
|
24
|
+
const db = new DatabaseSync(":memory:");
|
|
25
|
+
return {
|
|
26
|
+
exec: (sql) => db.exec(sql),
|
|
27
|
+
run: (sql, params = []) => {
|
|
28
|
+
const stmt = db.prepare(sql);
|
|
29
|
+
const r = stmt.run(...toParams(params));
|
|
30
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
31
|
+
},
|
|
32
|
+
all: (sql, params = []) => {
|
|
33
|
+
const stmt = db.prepare(sql);
|
|
34
|
+
// Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
|
|
35
|
+
// A JSON round-trip would throw on `bigint` values and mangle `Uint8Array`
|
|
36
|
+
// blobs, diverging from production and breaking test-only reads.
|
|
37
|
+
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
38
|
+
return stmt.all(...toParams(params));
|
|
39
|
+
},
|
|
40
|
+
close: () => db.close(),
|
|
41
|
+
};
|
|
42
|
+
}
|