@spexcode/session-runtime 0.6.8
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/dist/errors.d.ts +6 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +193 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +37 -0
- package/package.json +24 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type RuntimeBindingErrorCode = 'RUNTIME_BINDING_TRANSACTION_INVALID' | 'RUNTIME_BINDING_NAMESPACE_INVALID' | 'RUNTIME_BINDING_SESSION_ID_INVALID' | 'RUNTIME_BINDING_SESSION_UNKNOWN' | 'RUNTIME_BINDING_SESSION_RETIRED' | 'RUNTIME_BINDING_IDENTITY_INVALID' | 'RUNTIME_BINDING_METADATA_INVALID' | 'RUNTIME_BINDING_GENERATION_REQUIRED' | 'RUNTIME_BINDING_GENERATION_STALE' | 'RUNTIME_BINDING_NOT_FOUND' | 'RUNTIME_BINDING_NOT_BOUND' | 'RUNTIME_BINDING_STORAGE';
|
|
2
|
+
export declare class RuntimeBindingError extends Error {
|
|
3
|
+
readonly code: RuntimeBindingErrorCode;
|
|
4
|
+
constructor(code: RuntimeBindingErrorCode, message: string, cause?: unknown);
|
|
5
|
+
}
|
|
6
|
+
export declare function failRuntimeBinding(code: RuntimeBindingErrorCode, message: string, cause?: unknown): never;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class RuntimeBindingError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message, cause) {
|
|
4
|
+
super(message, { cause });
|
|
5
|
+
this.name = 'RuntimeBindingError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function failRuntimeBinding(code, message, cause) {
|
|
10
|
+
throw new RuntimeBindingError(code, message, cause);
|
|
11
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ProtocolTransaction, SessionProtocol } from '@spexcode/session-protocol';
|
|
2
|
+
import { RuntimeBindingError } from './errors.js';
|
|
3
|
+
export interface RuntimeIdentity {
|
|
4
|
+
namespace: string;
|
|
5
|
+
runtimeKind: string;
|
|
6
|
+
nativeSessionId: string;
|
|
7
|
+
nativeStartToken: string;
|
|
8
|
+
metadata?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface RuntimeBinding {
|
|
11
|
+
namespace: string;
|
|
12
|
+
protocolSessionId: string;
|
|
13
|
+
runtimeKind: string;
|
|
14
|
+
nativeSessionId: string;
|
|
15
|
+
nativeStartToken: string;
|
|
16
|
+
bindingGeneration: number;
|
|
17
|
+
status: 'bound' | 'unbound';
|
|
18
|
+
boundAtMs: number;
|
|
19
|
+
unboundAtMs: number | null;
|
|
20
|
+
metadata: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export interface BindingOptions {
|
|
23
|
+
expectedGeneration?: number;
|
|
24
|
+
now?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface SessionRuntimeBindings {
|
|
27
|
+
bind(tx: ProtocolTransaction, protocolSessionId: string, identity: RuntimeIdentity, options?: BindingOptions): RuntimeBinding;
|
|
28
|
+
unbind(tx: ProtocolTransaction, namespace: string, protocolSessionId: string, options?: BindingOptions): RuntimeBinding;
|
|
29
|
+
resolve(namespace: string, protocolSessionId: string, tx?: ProtocolTransaction): RuntimeBinding | null;
|
|
30
|
+
}
|
|
31
|
+
export declare function openRuntimeBindings(protocol: SessionProtocol): SessionRuntimeBindings;
|
|
32
|
+
export { RuntimeBindingError };
|
|
33
|
+
export type { RuntimeBindingErrorCode } from './errors.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { applyComponentMigrations } from '@spexcode/session-protocol';
|
|
2
|
+
import { failRuntimeBinding, RuntimeBindingError } from './errors.js';
|
|
3
|
+
import { RUNTIME_BINDINGS_MIGRATIONS } from './schema.js';
|
|
4
|
+
const NAMESPACE = /^[0-9A-Za-z._:/-]{1,128}$/;
|
|
5
|
+
const SESSION_ID = /^(?!-)[0-9A-Za-z_-]{1,256}$/;
|
|
6
|
+
const RUNTIME_KIND = /^[0-9A-Za-z._:-]{1,64}$/;
|
|
7
|
+
const MAX_METADATA_BYTES = 8192;
|
|
8
|
+
const SELECT_COLUMNS = `namespace, protocol_session_id, runtime_kind, native_session_id,
|
|
9
|
+
native_start_token, binding_generation, status, bound_at_ms, unbound_at_ms, metadata_json`;
|
|
10
|
+
export function openRuntimeBindings(protocol) {
|
|
11
|
+
applyComponentMigrations(protocol, 'session-runtime-bindings', RUNTIME_BINDINGS_MIGRATIONS);
|
|
12
|
+
const requireTransaction = (tx) => {
|
|
13
|
+
if (!tx || typeof tx.exec !== 'function' || typeof tx.query !== 'function') {
|
|
14
|
+
failRuntimeBinding('RUNTIME_BINDING_TRANSACTION_INVALID', 'a live protocol transaction context is required');
|
|
15
|
+
}
|
|
16
|
+
return tx;
|
|
17
|
+
};
|
|
18
|
+
const requireNamespace = (namespace) => {
|
|
19
|
+
if (typeof namespace !== 'string' || !NAMESPACE.test(namespace)) {
|
|
20
|
+
failRuntimeBinding('RUNTIME_BINDING_NAMESPACE_INVALID', 'namespace has an invalid grammar');
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const requireSessionId = (sessionId) => {
|
|
24
|
+
if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) {
|
|
25
|
+
failRuntimeBinding('RUNTIME_BINDING_SESSION_ID_INVALID', 'protocol session id has an invalid grammar');
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const requireIdentity = (identity) => {
|
|
29
|
+
if (!identity || typeof identity !== 'object') {
|
|
30
|
+
failRuntimeBinding('RUNTIME_BINDING_IDENTITY_INVALID', 'runtime identity must be an object');
|
|
31
|
+
}
|
|
32
|
+
requireNamespace(identity.namespace);
|
|
33
|
+
if (typeof identity.runtimeKind !== 'string' || !RUNTIME_KIND.test(identity.runtimeKind)) {
|
|
34
|
+
failRuntimeBinding('RUNTIME_BINDING_IDENTITY_INVALID', 'runtime kind has an invalid grammar');
|
|
35
|
+
}
|
|
36
|
+
if (typeof identity.nativeSessionId !== 'string' || identity.nativeSessionId.length < 1 || identity.nativeSessionId.length > 512) {
|
|
37
|
+
failRuntimeBinding('RUNTIME_BINDING_IDENTITY_INVALID', 'native session id must be nonempty and bounded');
|
|
38
|
+
}
|
|
39
|
+
if (typeof identity.nativeStartToken !== 'string' || identity.nativeStartToken.length < 1 || identity.nativeStartToken.length > 512) {
|
|
40
|
+
failRuntimeBinding('RUNTIME_BINDING_IDENTITY_INVALID', 'native start token must be nonempty and bounded');
|
|
41
|
+
}
|
|
42
|
+
const metadata = identity.metadata ?? {};
|
|
43
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
44
|
+
failRuntimeBinding('RUNTIME_BINDING_METADATA_INVALID', 'metadata must be a JSON object');
|
|
45
|
+
}
|
|
46
|
+
let encoded;
|
|
47
|
+
try {
|
|
48
|
+
encoded = JSON.stringify(metadata);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
failRuntimeBinding('RUNTIME_BINDING_METADATA_INVALID', 'metadata must be JSON serializable', error);
|
|
52
|
+
}
|
|
53
|
+
if (encoded === undefined || Buffer.byteLength(encoded, 'utf8') > MAX_METADATA_BYTES) {
|
|
54
|
+
failRuntimeBinding('RUNTIME_BINDING_METADATA_INVALID', 'metadata exceeds the 8192-byte limit');
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const roundTrip = JSON.parse(encoded);
|
|
58
|
+
if (!roundTrip || typeof roundTrip !== 'object' || Array.isArray(roundTrip)) {
|
|
59
|
+
failRuntimeBinding('RUNTIME_BINDING_METADATA_INVALID', 'metadata must round-trip as an object');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
failRuntimeBinding('RUNTIME_BINDING_METADATA_INVALID', 'metadata must be valid JSON', error);
|
|
64
|
+
}
|
|
65
|
+
return encoded;
|
|
66
|
+
};
|
|
67
|
+
const requireNow = (now) => {
|
|
68
|
+
const value = now ?? Date.now();
|
|
69
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
70
|
+
failRuntimeBinding('RUNTIME_BINDING_IDENTITY_INVALID', 'timestamp must be a non-negative safe integer');
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
const addressState = (tx, sessionId) => {
|
|
75
|
+
const rows = tx.query('SELECT retired_at_ms FROM protocol_sessions WHERE session_id=?', sessionId);
|
|
76
|
+
if (rows.length === 0)
|
|
77
|
+
failRuntimeBinding('RUNTIME_BINDING_SESSION_UNKNOWN', `unknown protocol session: ${sessionId}`);
|
|
78
|
+
return rows[0].retired_at_ms === null ? 'active' : 'retired';
|
|
79
|
+
};
|
|
80
|
+
const rowToBinding = (row) => {
|
|
81
|
+
let metadata;
|
|
82
|
+
try {
|
|
83
|
+
metadata = JSON.parse(row.metadata_json);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
failRuntimeBinding('RUNTIME_BINDING_STORAGE', 'stored metadata is not valid JSON', error);
|
|
87
|
+
}
|
|
88
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
89
|
+
failRuntimeBinding('RUNTIME_BINDING_STORAGE', 'stored metadata is not a JSON object');
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
namespace: String(row.namespace),
|
|
93
|
+
protocolSessionId: String(row.protocol_session_id),
|
|
94
|
+
runtimeKind: String(row.runtime_kind),
|
|
95
|
+
nativeSessionId: String(row.native_session_id),
|
|
96
|
+
nativeStartToken: String(row.native_start_token),
|
|
97
|
+
bindingGeneration: Number(row.binding_generation),
|
|
98
|
+
status: row.status,
|
|
99
|
+
boundAtMs: Number(row.bound_at_ms),
|
|
100
|
+
unboundAtMs: row.unbound_at_ms === null ? null : Number(row.unbound_at_ms),
|
|
101
|
+
metadata: metadata,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const read = (tx, namespace, sessionId) => {
|
|
105
|
+
const rows = tx.query(`SELECT ${SELECT_COLUMNS} FROM session_runtime_bindings WHERE namespace=? AND protocol_session_id=?`, namespace, sessionId);
|
|
106
|
+
return rows.length === 0 ? null : rowToBinding(rows[0]);
|
|
107
|
+
};
|
|
108
|
+
const checkGeneration = (existing, expectedGeneration) => {
|
|
109
|
+
if (!existing) {
|
|
110
|
+
if (expectedGeneration !== undefined) {
|
|
111
|
+
failRuntimeBinding('RUNTIME_BINDING_GENERATION_STALE', 'expected generation has no binding to match');
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (expectedGeneration === undefined) {
|
|
116
|
+
failRuntimeBinding('RUNTIME_BINDING_GENERATION_REQUIRED', 'expected generation is required for an existing binding');
|
|
117
|
+
}
|
|
118
|
+
if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration !== existing.bindingGeneration) {
|
|
119
|
+
failRuntimeBinding('RUNTIME_BINDING_GENERATION_STALE', 'binding generation is stale');
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const bind = (txInput, protocolSessionId, identity, options = {}) => {
|
|
123
|
+
const tx = requireTransaction(txInput);
|
|
124
|
+
requireSessionId(protocolSessionId);
|
|
125
|
+
const metadataJson = requireIdentity(identity);
|
|
126
|
+
if (addressState(tx, protocolSessionId) === 'retired') {
|
|
127
|
+
failRuntimeBinding('RUNTIME_BINDING_SESSION_RETIRED', `protocol session is retired: ${protocolSessionId}`);
|
|
128
|
+
}
|
|
129
|
+
const existing = read(tx, identity.namespace, protocolSessionId);
|
|
130
|
+
checkGeneration(existing, options.expectedGeneration);
|
|
131
|
+
const generation = existing ? existing.bindingGeneration + 1 : 1;
|
|
132
|
+
const now = requireNow(options.now);
|
|
133
|
+
try {
|
|
134
|
+
if (existing) {
|
|
135
|
+
tx.exec(`UPDATE session_runtime_bindings SET runtime_kind=?, native_session_id=?, native_start_token=?,
|
|
136
|
+
binding_generation=?, status='bound', bound_at_ms=?, unbound_at_ms=NULL, metadata_json=?
|
|
137
|
+
WHERE namespace=? AND protocol_session_id=?`, identity.runtimeKind, identity.nativeSessionId, identity.nativeStartToken, generation, now, metadataJson, identity.namespace, protocolSessionId);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
tx.exec(`INSERT INTO session_runtime_bindings
|
|
141
|
+
(namespace, protocol_session_id, runtime_kind, native_session_id, native_start_token,
|
|
142
|
+
binding_generation, status, bound_at_ms, unbound_at_ms, metadata_json)
|
|
143
|
+
VALUES (?,?,?,?,?,?, 'bound', ?, NULL, ?)`, identity.namespace, protocolSessionId, identity.runtimeKind, identity.nativeSessionId, identity.nativeStartToken, generation, now, metadataJson);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof RuntimeBindingError)
|
|
148
|
+
throw error;
|
|
149
|
+
failRuntimeBinding('RUNTIME_BINDING_STORAGE', 'failed to persist runtime binding', error);
|
|
150
|
+
}
|
|
151
|
+
const result = read(tx, identity.namespace, protocolSessionId);
|
|
152
|
+
if (!result)
|
|
153
|
+
failRuntimeBinding('RUNTIME_BINDING_STORAGE', 'binding disappeared after bind');
|
|
154
|
+
return result;
|
|
155
|
+
};
|
|
156
|
+
const unbind = (txInput, namespace, protocolSessionId, options = {}) => {
|
|
157
|
+
const tx = requireTransaction(txInput);
|
|
158
|
+
requireNamespace(namespace);
|
|
159
|
+
requireSessionId(protocolSessionId);
|
|
160
|
+
if (addressState(tx, protocolSessionId) === 'retired') {
|
|
161
|
+
failRuntimeBinding('RUNTIME_BINDING_SESSION_RETIRED', `protocol session is retired: ${protocolSessionId}`);
|
|
162
|
+
}
|
|
163
|
+
const existing = read(tx, namespace, protocolSessionId);
|
|
164
|
+
if (!existing)
|
|
165
|
+
failRuntimeBinding('RUNTIME_BINDING_NOT_FOUND', 'runtime binding does not exist');
|
|
166
|
+
checkGeneration(existing, options.expectedGeneration);
|
|
167
|
+
if (existing.status !== 'bound')
|
|
168
|
+
failRuntimeBinding('RUNTIME_BINDING_NOT_BOUND', 'runtime binding is already unbound');
|
|
169
|
+
const now = requireNow(options.now);
|
|
170
|
+
try {
|
|
171
|
+
tx.exec(`UPDATE session_runtime_bindings SET status='unbound', binding_generation=?, unbound_at_ms=?
|
|
172
|
+
WHERE namespace=? AND protocol_session_id=? AND binding_generation=?`, existing.bindingGeneration + 1, now, namespace, protocolSessionId, existing.bindingGeneration);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof RuntimeBindingError)
|
|
176
|
+
throw error;
|
|
177
|
+
failRuntimeBinding('RUNTIME_BINDING_STORAGE', 'failed to persist runtime unbinding', error);
|
|
178
|
+
}
|
|
179
|
+
const result = read(tx, namespace, protocolSessionId);
|
|
180
|
+
if (!result)
|
|
181
|
+
failRuntimeBinding('RUNTIME_BINDING_STORAGE', 'binding disappeared after unbind');
|
|
182
|
+
return result;
|
|
183
|
+
};
|
|
184
|
+
const resolve = (namespace, protocolSessionId, txInput) => {
|
|
185
|
+
requireNamespace(namespace);
|
|
186
|
+
requireSessionId(protocolSessionId);
|
|
187
|
+
if (txInput)
|
|
188
|
+
return read(requireTransaction(txInput), namespace, protocolSessionId);
|
|
189
|
+
return protocol.withTransaction(tx => read(tx, namespace, protocolSessionId));
|
|
190
|
+
};
|
|
191
|
+
return { bind, unbind, resolve };
|
|
192
|
+
}
|
|
193
|
+
export { RuntimeBindingError };
|
package/dist/schema.d.ts
ADDED
package/dist/schema.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const RUNTIME_BINDINGS_MIGRATIONS = [
|
|
2
|
+
{
|
|
3
|
+
version: 1,
|
|
4
|
+
sql: `
|
|
5
|
+
CREATE TABLE session_runtime_bindings (
|
|
6
|
+
namespace TEXT NOT NULL,
|
|
7
|
+
protocol_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),
|
|
8
|
+
runtime_kind TEXT NOT NULL,
|
|
9
|
+
native_session_id TEXT NOT NULL,
|
|
10
|
+
native_start_token TEXT NOT NULL,
|
|
11
|
+
binding_generation INTEGER NOT NULL,
|
|
12
|
+
status TEXT NOT NULL,
|
|
13
|
+
bound_at_ms INTEGER NOT NULL,
|
|
14
|
+
unbound_at_ms INTEGER,
|
|
15
|
+
metadata_json TEXT NOT NULL,
|
|
16
|
+
PRIMARY KEY (namespace, protocol_session_id),
|
|
17
|
+
CHECK (length(namespace) BETWEEN 1 AND 128),
|
|
18
|
+
CHECK (namespace NOT GLOB '*[^0-9A-Za-z._:/-]*'),
|
|
19
|
+
CHECK (length(runtime_kind) BETWEEN 1 AND 64),
|
|
20
|
+
CHECK (runtime_kind NOT GLOB '*[^0-9A-Za-z._:-]*'),
|
|
21
|
+
CHECK (length(native_session_id) BETWEEN 1 AND 512),
|
|
22
|
+
CHECK (length(native_start_token) BETWEEN 1 AND 512),
|
|
23
|
+
CHECK (binding_generation >= 1),
|
|
24
|
+
CHECK (status IN ('bound', 'unbound')),
|
|
25
|
+
CHECK (bound_at_ms >= 0),
|
|
26
|
+
CHECK (unbound_at_ms IS NULL OR unbound_at_ms >= bound_at_ms),
|
|
27
|
+
CHECK (json_valid(metadata_json) AND json_type(metadata_json) = 'object'),
|
|
28
|
+
CHECK (length(metadata_json) <= 8192),
|
|
29
|
+
CHECK ((status = 'bound' AND unbound_at_ms IS NULL)
|
|
30
|
+
OR (status = 'unbound' AND unbound_at_ms IS NOT NULL))
|
|
31
|
+
) STRICT;
|
|
32
|
+
|
|
33
|
+
CREATE INDEX session_runtime_bindings_native
|
|
34
|
+
ON session_runtime_bindings (runtime_kind, native_session_id, status);
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spexcode/session-runtime",
|
|
3
|
+
"version": "0.6.8",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Adopter-owned bindings between protocol addresses and native runtime identities.",
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"engines": { "node": ">=22" },
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "node ../../scripts/build-dist.mjs",
|
|
15
|
+
"prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
|
|
16
|
+
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": { "@spexcode/session-protocol": "0.6.8" },
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^20.16.0",
|
|
21
|
+
"tsx": "^4.19.2",
|
|
22
|
+
"typescript": "^5.6.3"
|
|
23
|
+
}
|
|
24
|
+
}
|