@spexcode/session-topology 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.
@@ -0,0 +1,6 @@
1
+ export type TopologyErrorCode = 'TOPOLOGY_EDGE_ID_INVALID' | 'TOPOLOGY_EDGE_UNKNOWN' | 'TOPOLOGY_EDGE_EXISTS' | 'TOPOLOGY_CYCLE_REFUSED' | 'TOPOLOGY_RELATION_TYPE_INVALID' | 'TOPOLOGY_SESSION_ID_INVALID' | 'TOPOLOGY_SESSION_UNKNOWN' | 'TOPOLOGY_SELF_EDGE' | 'TOPOLOGY_TRANSACTION_INVALID' | 'TOPOLOGY_STORAGE_ERROR';
2
+ export declare class TopologyError extends Error {
3
+ readonly code: TopologyErrorCode;
4
+ constructor(code: TopologyErrorCode, message: string, cause?: unknown);
5
+ }
6
+ export declare function failTopology(code: TopologyErrorCode, message: string, cause?: unknown): never;
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ export class TopologyError extends Error {
2
+ code;
3
+ constructor(code, message, cause) {
4
+ super(message, { cause });
5
+ this.name = 'TopologyError';
6
+ this.code = code;
7
+ }
8
+ }
9
+ export function failTopology(code, message, cause) {
10
+ throw new TopologyError(code, message, cause);
11
+ }
@@ -0,0 +1,24 @@
1
+ import type { ProtocolTransaction, SessionProtocol } from '@spexcode/session-protocol';
2
+ import { TopologyError } from './errors.js';
3
+ export interface TopologyEdge {
4
+ edgeId: string;
5
+ fromSessionId: string;
6
+ toSessionId: string;
7
+ relationType: string;
8
+ createdAtMs: number;
9
+ removedAtMs: number | null;
10
+ }
11
+ export interface SessionTopology {
12
+ attach(tx: ProtocolTransaction, fromSessionId: string, toSessionId: string, relationType: string): TopologyEdge;
13
+ detach(tx: ProtocolTransaction, edgeId: string): TopologyEdge;
14
+ reparent(tx: ProtocolTransaction, subjectSessionId: string, nextFromSessionId: string, relationType: string): TopologyEdge;
15
+ subscribe(tx: ProtocolTransaction, watcherSessionId: string, subjectSessionId: string, channel: string): TopologyEdge;
16
+ unsubscribe(tx: ProtocolTransaction, watcherSessionId: string, subjectSessionId: string, channel: string): TopologyEdge;
17
+ parents(sessionId: string, relationType?: string, tx?: ProtocolTransaction): TopologyEdge[];
18
+ children(sessionId: string, relationType?: string, tx?: ProtocolTransaction): TopologyEdge[];
19
+ subscriptions(sessionId: string, tx?: ProtocolTransaction): TopologyEdge[];
20
+ recipients(subjectSessionId: string, tx?: ProtocolTransaction): string[];
21
+ }
22
+ export declare function openTopology(protocol: SessionProtocol): SessionTopology;
23
+ export { TopologyError };
24
+ export type { TopologyErrorCode } from './errors.js';
package/dist/index.js ADDED
@@ -0,0 +1,173 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { applyComponentMigrations } from '@spexcode/session-protocol';
3
+ import { failTopology, TopologyError } from './errors.js';
4
+ import { TOPOLOGY_MIGRATIONS } from './schema.js';
5
+ const EDGE_ID = /^[0-9a-f]{32}$/;
6
+ const RELATION_TYPE = /^[0-9A-Za-z._:-]{1,64}$/;
7
+ const SESSION_ID = /^(?!-)[0-9A-Za-z_-]{1,256}$/;
8
+ const EDGE_COLUMNS = `edge_id, from_session_id, to_session_id, relation_type, created_at_ms, removed_at_ms`;
9
+ export function openTopology(protocol) {
10
+ applyComponentMigrations(protocol, 'session-topology', TOPOLOGY_MIGRATIONS);
11
+ const requireTransaction = (tx) => {
12
+ if (!tx
13
+ || typeof tx.exec !== 'function'
14
+ || typeof tx.query !== 'function'
15
+ || typeof tx.enqueue !== 'function') {
16
+ failTopology('TOPOLOGY_TRANSACTION_INVALID', 'a live protocol transaction context is required');
17
+ }
18
+ return tx;
19
+ };
20
+ const requireSessionId = (sessionId) => {
21
+ if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) {
22
+ failTopology('TOPOLOGY_SESSION_ID_INVALID', 'session id must match the protocol address grammar');
23
+ }
24
+ };
25
+ const requireRelationType = (relationType) => {
26
+ if (typeof relationType !== 'string' || !RELATION_TYPE.test(relationType)) {
27
+ failTopology('TOPOLOGY_RELATION_TYPE_INVALID', 'relation type must match [0-9A-Za-z._:-]{1,64}');
28
+ }
29
+ };
30
+ const requireEdgeId = (edgeId) => {
31
+ if (typeof edgeId !== 'string' || !EDGE_ID.test(edgeId)) {
32
+ failTopology('TOPOLOGY_EDGE_ID_INVALID', 'edge id must be 32 lowercase hexadecimal characters');
33
+ }
34
+ };
35
+ const toEdge = (row) => ({
36
+ edgeId: String(row.edge_id),
37
+ fromSessionId: String(row.from_session_id),
38
+ toSessionId: String(row.to_session_id),
39
+ relationType: String(row.relation_type),
40
+ createdAtMs: Number(row.created_at_ms),
41
+ removedAtMs: row.removed_at_ms === null ? null : Number(row.removed_at_ms),
42
+ });
43
+ const asEdgeRows = (rows) => rows;
44
+ const requireAddresses = (tx, fromSessionId, toSessionId) => {
45
+ const rows = tx.query('SELECT session_id FROM protocol_sessions WHERE session_id IN (?,?) ORDER BY session_id', fromSessionId, toSessionId);
46
+ const found = new Set(rows.map(row => String(row.session_id)));
47
+ if (!found.has(fromSessionId) || !found.has(toSessionId)) {
48
+ failTopology('TOPOLOGY_SESSION_UNKNOWN', 'one or more topology session addresses are unknown');
49
+ }
50
+ };
51
+ const cycleExists = (tx, fromSessionId, toSessionId, relationType) => tx.query(`WITH RECURSIVE reachable(session_id) AS (
52
+ VALUES (?)
53
+ UNION
54
+ SELECT edge.to_session_id
55
+ FROM topology_edges AS edge INDEXED BY topology_active_edge
56
+ JOIN reachable ON edge.from_session_id=reachable.session_id
57
+ WHERE edge.relation_type=? AND edge.removed_at_ms IS NULL
58
+ )
59
+ SELECT 1 AS present FROM reachable WHERE session_id=? LIMIT 1`, toSessionId, relationType, fromSessionId).length > 0;
60
+ const validateNewEdge = (tx, fromSessionId, toSessionId, relationType) => {
61
+ requireTransaction(tx);
62
+ requireSessionId(fromSessionId);
63
+ requireSessionId(toSessionId);
64
+ requireRelationType(relationType);
65
+ if (fromSessionId === toSessionId) {
66
+ failTopology('TOPOLOGY_SELF_EDGE', 'an edge cannot point from a session to itself');
67
+ }
68
+ requireAddresses(tx, fromSessionId, toSessionId);
69
+ if (cycleExists(tx, fromSessionId, toSessionId, relationType)) {
70
+ failTopology('TOPOLOGY_CYCLE_REFUSED', 'the edge would create a relation cycle');
71
+ }
72
+ };
73
+ const activeEdge = (tx, fromSessionId, toSessionId, relationType) => asEdgeRows(tx.query(`SELECT ${EDGE_COLUMNS} FROM topology_edges INDEXED BY topology_active_edge
74
+ WHERE from_session_id=? AND to_session_id=? AND relation_type=? AND removed_at_ms IS NULL`, fromSessionId, toSessionId, relationType))[0];
75
+ const insertEdge = (tx, fromSessionId, toSessionId, relationType) => {
76
+ const edgeId = randomBytes(16).toString('hex');
77
+ const createdAtMs = Date.now();
78
+ try {
79
+ tx.exec(`INSERT INTO topology_edges
80
+ (edge_id, from_session_id, to_session_id, relation_type, created_at_ms, removed_at_ms)
81
+ VALUES (?,?,?,?,?,NULL)`, edgeId, fromSessionId, toSessionId, relationType, createdAtMs);
82
+ }
83
+ catch (error) {
84
+ const code = String(error?.code ?? '');
85
+ if (code.includes('UNIQUE') || code.includes('PRIMARYKEY')) {
86
+ failTopology('TOPOLOGY_EDGE_EXISTS', 'the active topology edge already exists', error);
87
+ }
88
+ if (code.includes('FOREIGNKEY')) {
89
+ failTopology('TOPOLOGY_SESSION_UNKNOWN', 'one or more topology session addresses are unknown', error);
90
+ }
91
+ failTopology('TOPOLOGY_STORAGE_ERROR', 'the topology edge could not be stored', error);
92
+ }
93
+ return { edgeId, fromSessionId, toSessionId, relationType, createdAtMs, removedAtMs: null };
94
+ };
95
+ const attach = (tx, fromSessionId, toSessionId, relationType) => {
96
+ validateNewEdge(tx, fromSessionId, toSessionId, relationType);
97
+ if (activeEdge(tx, fromSessionId, toSessionId, relationType)) {
98
+ failTopology('TOPOLOGY_EDGE_EXISTS', 'the active topology edge already exists');
99
+ }
100
+ return insertEdge(tx, fromSessionId, toSessionId, relationType);
101
+ };
102
+ const detach = (tx, edgeId) => {
103
+ requireTransaction(tx);
104
+ requireEdgeId(edgeId);
105
+ const row = asEdgeRows(tx.query(`SELECT ${EDGE_COLUMNS} FROM topology_edges WHERE edge_id=? AND removed_at_ms IS NULL`, edgeId))[0];
106
+ if (!row)
107
+ failTopology('TOPOLOGY_EDGE_UNKNOWN', 'the active topology edge does not exist');
108
+ const removedAtMs = Date.now();
109
+ const result = tx.exec('UPDATE topology_edges SET removed_at_ms=? WHERE edge_id=? AND removed_at_ms IS NULL', removedAtMs, edgeId);
110
+ if (result.changes !== 1) {
111
+ failTopology('TOPOLOGY_EDGE_UNKNOWN', 'the active topology edge does not exist');
112
+ }
113
+ return { ...toEdge(row), removedAtMs };
114
+ };
115
+ const withRead = (tx, query) => {
116
+ if (tx !== undefined)
117
+ return query(requireTransaction(tx));
118
+ return protocol.withTransaction(query);
119
+ };
120
+ const queryEdges = (sessionId, relationType, tx, direction) => {
121
+ requireSessionId(sessionId);
122
+ if (relationType !== undefined)
123
+ requireRelationType(relationType);
124
+ return withRead(tx, active => {
125
+ const index = direction === 'from' ? 'topology_active_edge' : 'topology_active_to';
126
+ const column = direction === 'from' ? 'from_session_id' : 'to_session_id';
127
+ const relation = relationType === undefined ? '' : ' AND relation_type=?';
128
+ const params = relationType === undefined ? [sessionId] : [sessionId, relationType];
129
+ return asEdgeRows(active.query(`SELECT ${EDGE_COLUMNS} FROM topology_edges INDEXED BY ${index}
130
+ WHERE ${column}=? AND removed_at_ms IS NULL${relation}
131
+ ORDER BY relation_type, from_session_id, to_session_id, created_at_ms, edge_id`, ...params)).map(toEdge);
132
+ });
133
+ };
134
+ return {
135
+ attach,
136
+ detach,
137
+ reparent(tx, subjectSessionId, nextFromSessionId, relationType) {
138
+ validateNewEdge(tx, nextFromSessionId, subjectSessionId, relationType);
139
+ const removedAtMs = Date.now();
140
+ tx.exec(`UPDATE topology_edges SET removed_at_ms=?
141
+ WHERE to_session_id=? AND relation_type=? AND removed_at_ms IS NULL`, removedAtMs, subjectSessionId, relationType);
142
+ return insertEdge(tx, nextFromSessionId, subjectSessionId, relationType);
143
+ },
144
+ subscribe(tx, watcherSessionId, subjectSessionId, channel) {
145
+ return attach(tx, watcherSessionId, subjectSessionId, channel);
146
+ },
147
+ unsubscribe(tx, watcherSessionId, subjectSessionId, channel) {
148
+ requireTransaction(tx);
149
+ requireSessionId(watcherSessionId);
150
+ requireSessionId(subjectSessionId);
151
+ requireRelationType(channel);
152
+ const row = activeEdge(tx, watcherSessionId, subjectSessionId, channel);
153
+ if (!row)
154
+ failTopology('TOPOLOGY_EDGE_UNKNOWN', 'the active topology edge does not exist');
155
+ return detach(tx, String(row.edge_id));
156
+ },
157
+ parents(sessionId, relationType, tx) {
158
+ return queryEdges(sessionId, relationType, tx, 'to');
159
+ },
160
+ children(sessionId, relationType, tx) {
161
+ return queryEdges(sessionId, relationType, tx, 'from');
162
+ },
163
+ subscriptions(sessionId, tx) {
164
+ return queryEdges(sessionId, undefined, tx, 'from');
165
+ },
166
+ recipients(subjectSessionId, tx) {
167
+ requireSessionId(subjectSessionId);
168
+ return withRead(tx, active => active.query(`SELECT DISTINCT from_session_id FROM topology_edges INDEXED BY topology_active_to
169
+ WHERE to_session_id=? AND removed_at_ms IS NULL ORDER BY from_session_id`, subjectSessionId).map(row => String(row.from_session_id)));
170
+ },
171
+ };
172
+ }
173
+ export { TopologyError };
@@ -0,0 +1,3 @@
1
+ import type { ComponentMigration } from '@spexcode/session-protocol';
2
+ export declare const TOPOLOGY_MIGRATION_SQL = "\nCREATE TABLE topology_edges (\n edge_id TEXT NOT NULL PRIMARY KEY,\n from_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),\n to_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),\n relation_type TEXT NOT NULL,\n created_at_ms INTEGER NOT NULL,\n removed_at_ms INTEGER,\n CHECK (length(edge_id) = 32 AND edge_id NOT GLOB '*[^0-9a-f]*'),\n CHECK (from_session_id <> to_session_id),\n CHECK (length(relation_type) BETWEEN 1 AND 64),\n CHECK (relation_type NOT GLOB '*[^0-9A-Za-z._:-]*'),\n CHECK (created_at_ms >= 0),\n CHECK (removed_at_ms IS NULL OR removed_at_ms >= 0)\n) STRICT;\n\nCREATE UNIQUE INDEX topology_active_edge\n ON topology_edges (from_session_id, to_session_id, relation_type)\n WHERE removed_at_ms IS NULL;\n\nCREATE INDEX topology_active_to\n ON topology_edges (to_session_id, relation_type, from_session_id)\n WHERE removed_at_ms IS NULL;\n";
3
+ export declare const TOPOLOGY_MIGRATIONS: readonly ComponentMigration[];
package/dist/schema.js ADDED
@@ -0,0 +1,27 @@
1
+ export const TOPOLOGY_MIGRATION_SQL = `
2
+ CREATE TABLE topology_edges (
3
+ edge_id TEXT NOT NULL PRIMARY KEY,
4
+ from_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),
5
+ to_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),
6
+ relation_type TEXT NOT NULL,
7
+ created_at_ms INTEGER NOT NULL,
8
+ removed_at_ms INTEGER,
9
+ CHECK (length(edge_id) = 32 AND edge_id NOT GLOB '*[^0-9a-f]*'),
10
+ CHECK (from_session_id <> to_session_id),
11
+ CHECK (length(relation_type) BETWEEN 1 AND 64),
12
+ CHECK (relation_type NOT GLOB '*[^0-9A-Za-z._:-]*'),
13
+ CHECK (created_at_ms >= 0),
14
+ CHECK (removed_at_ms IS NULL OR removed_at_ms >= 0)
15
+ ) STRICT;
16
+
17
+ CREATE UNIQUE INDEX topology_active_edge
18
+ ON topology_edges (from_session_id, to_session_id, relation_type)
19
+ WHERE removed_at_ms IS NULL;
20
+
21
+ CREATE INDEX topology_active_to
22
+ ON topology_edges (to_session_id, relation_type, from_session_id)
23
+ WHERE removed_at_ms IS NULL;
24
+ `;
25
+ export const TOPOLOGY_MIGRATIONS = [
26
+ { version: 1, sql: TOPOLOGY_MIGRATION_SQL },
27
+ ];
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@spexcode/session-topology",
3
+ "version": "0.6.8",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "description": "A neutral SQLite relation model for session addresses.",
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
+ }