@spexcode/session-events 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 SessionEventErrorCode = 'EVENT_TRANSACTION_INVALID' | 'EVENT_SESSION_ID_INVALID' | 'EVENT_SESSION_UNKNOWN' | 'EVENT_ID_INVALID' | 'EVENT_ID_EXISTS' | 'EVENT_TYPE_INVALID' | 'EVENT_SCHEMA_VERSION_INVALID' | 'EVENT_PAYLOAD_INVALID' | 'EVENT_TIMESTAMP_INVALID' | 'EVENT_SEQUENCE_INVALID' | 'EVENT_TYPE_UNKNOWN' | 'EVENT_JSON_INVALID' | 'EVENT_STORAGE';
2
+ export declare class SessionEventError extends Error {
3
+ readonly code: SessionEventErrorCode;
4
+ constructor(code: SessionEventErrorCode, message: string, cause?: unknown);
5
+ }
6
+ export declare function failSessionEvent(code: SessionEventErrorCode, message: string, cause?: unknown): never;
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ export class SessionEventError extends Error {
2
+ code;
3
+ constructor(code, message, cause) {
4
+ super(message, { cause });
5
+ this.name = 'SessionEventError';
6
+ this.code = code;
7
+ }
8
+ }
9
+ export function failSessionEvent(code, message, cause) {
10
+ throw new SessionEventError(code, message, cause);
11
+ }
@@ -0,0 +1,46 @@
1
+ import type { ProtocolTransaction, SessionProtocol } from '@spexcode/session-protocol';
2
+ export type JsonPrimitive = null | boolean | number | string;
3
+ export type JsonValue = JsonPrimitive | JsonValue[] | {
4
+ [key: string]: JsonValue;
5
+ };
6
+ export interface NewSessionEvent {
7
+ eventId: string;
8
+ type: string;
9
+ schemaVersion: number;
10
+ subjectSessionId: string;
11
+ payload: Uint8Array;
12
+ occurredAtMs: number;
13
+ ignorable?: boolean;
14
+ }
15
+ export interface SessionEvent {
16
+ eventId: string;
17
+ eventSeq: number;
18
+ type: string;
19
+ schemaVersion: number;
20
+ subjectSessionId: string;
21
+ payload: Uint8Array;
22
+ occurredAtMs: number;
23
+ ignorable: boolean;
24
+ }
25
+ export interface EventReadOptions {
26
+ afterSequence?: number;
27
+ atSequence?: number;
28
+ }
29
+ export type SessionEventReducer<State> = (state: State, event: SessionEvent) => State;
30
+ export type SessionEventReducers<State> = Readonly<Record<string, SessionEventReducer<State>>>;
31
+ export interface ReplayOptions<State> extends EventReadOptions {
32
+ initialState: State;
33
+ reducers: SessionEventReducers<State>;
34
+ }
35
+ export interface SessionEventStore {
36
+ append(tx: ProtocolTransaction, input: NewSessionEvent): SessionEvent;
37
+ hasMessageEvent(tx: ProtocolTransaction, subjectSessionId: string, messageId: string): boolean;
38
+ read(subjectSessionId: string, options?: EventReadOptions, tx?: ProtocolTransaction): readonly SessionEvent[];
39
+ replay<State>(subjectSessionId: string, options: ReplayOptions<State>, tx?: ProtocolTransaction): State;
40
+ }
41
+ export declare function encodeEventJson(value: unknown): Uint8Array;
42
+ export declare function decodeEventJson(payload: Uint8Array): JsonValue;
43
+ export declare function openSessionEvents(protocol: SessionProtocol): SessionEventStore;
44
+ export { SessionEventError } from './errors.js';
45
+ export type { SessionEventErrorCode } from './errors.js';
46
+ export { SESSION_EVENTS_MIGRATION_SQL, SESSION_EVENTS_MIGRATIONS } from './schema.js';
package/dist/index.js ADDED
@@ -0,0 +1,245 @@
1
+ import { applyComponentMigrations } from '@spexcode/session-protocol';
2
+ import { failSessionEvent, SessionEventError } from './errors.js';
3
+ import { SESSION_EVENTS_MIGRATIONS } from './schema.js';
4
+ const EVENT_ID = /^[0-9a-f]{32}$/;
5
+ const EVENT_TYPE = /^[0-9A-Za-z._:-]{1,128}$/;
6
+ const SESSION_ID = /^(?!-)[0-9A-Za-z_-]{1,256}$/;
7
+ const MAX_PAYLOAD_BYTES = 1_048_576;
8
+ const SELECT_COLUMNS = `subject_session_id, event_seq, event_id, event_type,
9
+ schema_version, ignorable, payload, occurred_at_ms`;
10
+ const requireTransaction = (tx) => {
11
+ if (!tx || typeof tx.exec !== 'function' || typeof tx.query !== 'function') {
12
+ failSessionEvent('EVENT_TRANSACTION_INVALID', 'a live protocol transaction context is required');
13
+ }
14
+ return tx;
15
+ };
16
+ const requireSessionId = (subjectSessionId) => {
17
+ if (typeof subjectSessionId !== 'string' || !SESSION_ID.test(subjectSessionId)) {
18
+ failSessionEvent('EVENT_SESSION_ID_INVALID', 'subject session id has an invalid grammar');
19
+ }
20
+ };
21
+ const requireSequence = (value, name, defaultValue) => {
22
+ const resolved = value ?? defaultValue;
23
+ if (!Number.isSafeInteger(resolved) || resolved < 0) {
24
+ failSessionEvent('EVENT_SEQUENCE_INVALID', `${name} must be a non-negative safe integer`);
25
+ }
26
+ return resolved;
27
+ };
28
+ const requireEnvelope = (input) => {
29
+ if (!input || typeof input !== 'object') {
30
+ failSessionEvent('EVENT_PAYLOAD_INVALID', 'event input must be an object');
31
+ }
32
+ if (typeof input.eventId !== 'string' || !EVENT_ID.test(input.eventId)) {
33
+ failSessionEvent('EVENT_ID_INVALID', 'event id must be exactly 32 lowercase hexadecimal characters');
34
+ }
35
+ if (typeof input.type !== 'string' || !EVENT_TYPE.test(input.type)) {
36
+ failSessionEvent('EVENT_TYPE_INVALID', 'event type has an invalid grammar');
37
+ }
38
+ if (!Number.isSafeInteger(input.schemaVersion) || input.schemaVersion < 1) {
39
+ failSessionEvent('EVENT_SCHEMA_VERSION_INVALID', 'schema version must be a positive safe integer');
40
+ }
41
+ requireSessionId(input.subjectSessionId);
42
+ if (!(input.payload instanceof Uint8Array) || input.payload.byteLength > MAX_PAYLOAD_BYTES) {
43
+ failSessionEvent('EVENT_PAYLOAD_INVALID', `payload must be Uint8Array at most ${MAX_PAYLOAD_BYTES} bytes`);
44
+ }
45
+ if (!Number.isSafeInteger(input.occurredAtMs) || input.occurredAtMs < 0) {
46
+ failSessionEvent('EVENT_TIMESTAMP_INVALID', 'occurredAtMs must be a non-negative safe integer');
47
+ }
48
+ if (input.ignorable !== undefined && typeof input.ignorable !== 'boolean') {
49
+ failSessionEvent('EVENT_PAYLOAD_INVALID', 'ignorable must be a boolean when present');
50
+ }
51
+ return new Uint8Array(input.payload);
52
+ };
53
+ const eventFromRow = (row) => Object.freeze({
54
+ eventId: String(row.event_id),
55
+ eventSeq: Number(row.event_seq),
56
+ type: String(row.event_type),
57
+ schemaVersion: Number(row.schema_version),
58
+ subjectSessionId: String(row.subject_session_id),
59
+ payload: new Uint8Array(row.payload),
60
+ occurredAtMs: Number(row.occurred_at_ms),
61
+ ignorable: Number(row.ignorable) === 1,
62
+ });
63
+ const snapshotJson = (value, path, active) => {
64
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
65
+ return value;
66
+ if (typeof value === 'number') {
67
+ if (!Number.isFinite(value) || Object.is(value, -0)) {
68
+ failSessionEvent('EVENT_JSON_INVALID', `${path} contains a non-lossless JSON number`);
69
+ }
70
+ return value;
71
+ }
72
+ if (typeof value !== 'object') {
73
+ failSessionEvent('EVENT_JSON_INVALID', `${path} contains a non-JSON value`);
74
+ }
75
+ if (active.has(value))
76
+ failSessionEvent('EVENT_JSON_INVALID', `${path} contains a cycle`);
77
+ active.add(value);
78
+ try {
79
+ if (Array.isArray(value)) {
80
+ const keys = Reflect.ownKeys(value);
81
+ if (keys.some(key => key !== 'length' && (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)))) {
82
+ failSessionEvent('EVENT_JSON_INVALID', `${path} contains non-index array properties`);
83
+ }
84
+ const result = [];
85
+ for (let index = 0; index < value.length; index++) {
86
+ if (!Object.prototype.hasOwnProperty.call(value, index)) {
87
+ failSessionEvent('EVENT_JSON_INVALID', `${path} contains a sparse array`);
88
+ }
89
+ result.push(snapshotJson(value[index], `${path}[${index}]`, active));
90
+ }
91
+ return result;
92
+ }
93
+ const prototype = Object.getPrototypeOf(value);
94
+ if (prototype !== Object.prototype && prototype !== null) {
95
+ failSessionEvent('EVENT_JSON_INVALID', `${path} must contain only plain JSON objects`);
96
+ }
97
+ const descriptors = Object.getOwnPropertyDescriptors(value);
98
+ const symbols = Object.getOwnPropertySymbols(value);
99
+ if (symbols.length > 0)
100
+ failSessionEvent('EVENT_JSON_INVALID', `${path} contains symbol keys`);
101
+ const result = {};
102
+ for (const key of Object.keys(descriptors).sort()) {
103
+ const descriptor = descriptors[key];
104
+ if (!descriptor.enumerable || !('value' in descriptor)) {
105
+ failSessionEvent('EVENT_JSON_INVALID', `${path}.${key} is not an enumerable data property`);
106
+ }
107
+ result[key] = snapshotJson(descriptor.value, `${path}.${key}`, active);
108
+ }
109
+ return result;
110
+ }
111
+ finally {
112
+ active.delete(value);
113
+ }
114
+ };
115
+ export function encodeEventJson(value) {
116
+ const snapshot = snapshotJson(value, '$', new Set());
117
+ return new TextEncoder().encode(JSON.stringify(snapshot));
118
+ }
119
+ export function decodeEventJson(payload) {
120
+ if (!(payload instanceof Uint8Array)) {
121
+ failSessionEvent('EVENT_JSON_INVALID', 'JSON payload must be Uint8Array');
122
+ }
123
+ let text;
124
+ try {
125
+ text = new TextDecoder('utf-8', { fatal: true }).decode(payload);
126
+ }
127
+ catch (error) {
128
+ failSessionEvent('EVENT_JSON_INVALID', 'JSON payload is not valid UTF-8', error);
129
+ }
130
+ try {
131
+ return snapshotJson(JSON.parse(text), '$', new Set());
132
+ }
133
+ catch (error) {
134
+ if (error instanceof SessionEventError)
135
+ throw error;
136
+ failSessionEvent('EVENT_JSON_INVALID', 'payload is not valid JSON', error);
137
+ }
138
+ }
139
+ export function openSessionEvents(protocol) {
140
+ try {
141
+ applyComponentMigrations(protocol, 'session-events', SESSION_EVENTS_MIGRATIONS);
142
+ }
143
+ catch (error) {
144
+ failSessionEvent('EVENT_STORAGE', 'session event component migration failed', error);
145
+ }
146
+ const requireSubject = (tx, subjectSessionId) => {
147
+ const rows = tx.query('SELECT 1 AS present FROM protocol_sessions WHERE session_id=?', subjectSessionId);
148
+ if (rows.length === 0) {
149
+ failSessionEvent('EVENT_SESSION_UNKNOWN', `unknown protocol session: ${subjectSessionId}`);
150
+ }
151
+ };
152
+ const append = (txInput, input) => {
153
+ const tx = requireTransaction(txInput);
154
+ const payload = requireEnvelope(input);
155
+ requireSubject(tx, input.subjectSessionId);
156
+ if (tx.query('SELECT 1 AS present FROM session_events WHERE event_id=?', input.eventId).length > 0) {
157
+ failSessionEvent('EVENT_ID_EXISTS', `event id already exists: ${input.eventId}`);
158
+ }
159
+ const nextRows = tx.query('SELECT COALESCE(MAX(event_seq), 0) + 1 AS next_seq FROM session_events WHERE subject_session_id=?', input.subjectSessionId);
160
+ const eventSeq = Number(nextRows[0]?.next_seq);
161
+ if (!Number.isSafeInteger(eventSeq) || eventSeq < 1) {
162
+ failSessionEvent('EVENT_SEQUENCE_INVALID', 'next event sequence is not a positive safe integer');
163
+ }
164
+ try {
165
+ tx.exec(`INSERT INTO session_events
166
+ (subject_session_id,event_seq,event_id,event_type,schema_version,ignorable,payload,occurred_at_ms)
167
+ VALUES(?,?,?,?,?,?,?,?)`, input.subjectSessionId, eventSeq, input.eventId, input.type, input.schemaVersion, input.ignorable === true ? 1 : 0, payload, input.occurredAtMs);
168
+ }
169
+ catch (error) {
170
+ failSessionEvent('EVENT_STORAGE', 'event insert failed', error);
171
+ }
172
+ return Object.freeze({
173
+ eventId: input.eventId,
174
+ eventSeq,
175
+ type: input.type,
176
+ schemaVersion: input.schemaVersion,
177
+ subjectSessionId: input.subjectSessionId,
178
+ payload: new Uint8Array(payload),
179
+ occurredAtMs: input.occurredAtMs,
180
+ ignorable: input.ignorable === true,
181
+ });
182
+ };
183
+ const readInTransaction = (txInput, subjectSessionId, options) => {
184
+ const tx = requireTransaction(txInput);
185
+ requireSessionId(subjectSessionId);
186
+ const address = tx.query('SELECT 1 AS present FROM protocol_sessions WHERE session_id=?', subjectSessionId);
187
+ if (address.length === 0) {
188
+ failSessionEvent('EVENT_SESSION_UNKNOWN', `unknown protocol session: ${subjectSessionId}`);
189
+ }
190
+ const afterSequence = requireSequence(options.afterSequence, 'afterSequence', 0);
191
+ const atSequence = requireSequence(options.atSequence, 'atSequence', Number.MAX_SAFE_INTEGER);
192
+ if (atSequence < afterSequence) {
193
+ failSessionEvent('EVENT_SEQUENCE_INVALID', 'atSequence must not precede afterSequence');
194
+ }
195
+ const rows = tx.query(`SELECT ${SELECT_COLUMNS} FROM session_events
196
+ WHERE subject_session_id=? AND event_seq>? AND event_seq<=? ORDER BY event_seq`, subjectSessionId, afterSequence, atSequence);
197
+ const events = rows.map(eventFromRow);
198
+ for (let index = 0; index < events.length; index++) {
199
+ const expected = afterSequence + index + 1;
200
+ if (events[index].eventSeq !== expected) {
201
+ failSessionEvent('EVENT_SEQUENCE_INVALID', `subject ${subjectSessionId} has a sequence gap: expected ${expected}, found ${events[index].eventSeq}`);
202
+ }
203
+ }
204
+ return Object.freeze(events);
205
+ };
206
+ const hasMessageEvent = (txInput, subjectSessionId, messageId) => {
207
+ const tx = requireTransaction(txInput);
208
+ requireSubject(tx, subjectSessionId);
209
+ if (typeof messageId !== 'string' || messageId.length === 0)
210
+ return false;
211
+ // Message events are JSON envelopes. Keep the lookup in the event package so adopters do not scan and
212
+ // decode an entire session history merely to make a retry idempotent. json_valid makes malformed legacy
213
+ // payloads non-matching instead of turning a delivery check into a transaction failure.
214
+ return tx.query(`SELECT 1 AS present FROM session_events
215
+ WHERE subject_session_id=? AND event_type='session.message.sent.v1'
216
+ AND json_valid(CAST(payload AS TEXT))
217
+ AND json_extract(CAST(payload AS TEXT), '$.messageId')=? LIMIT 1`, subjectSessionId, messageId).length > 0;
218
+ };
219
+ const read = (subjectSessionId, options = {}, tx) => {
220
+ if (tx !== undefined)
221
+ return readInTransaction(tx, subjectSessionId, options);
222
+ return protocol.withTransaction(active => readInTransaction(active, subjectSessionId, options));
223
+ };
224
+ const replay = (subjectSessionId, options, tx) => {
225
+ if (!options || typeof options !== 'object' || !options.reducers || typeof options.reducers !== 'object') {
226
+ failSessionEvent('EVENT_TYPE_UNKNOWN', 'replay requires a reducer table');
227
+ }
228
+ let state = options.initialState;
229
+ for (const event of read(subjectSessionId, options, tx)) {
230
+ const reducer = Object.prototype.hasOwnProperty.call(options.reducers, event.type)
231
+ ? options.reducers[event.type]
232
+ : undefined;
233
+ if (typeof reducer !== 'function') {
234
+ if (event.ignorable)
235
+ continue;
236
+ failSessionEvent('EVENT_TYPE_UNKNOWN', `required event type has no reducer: ${event.type}`);
237
+ }
238
+ state = reducer(state, event);
239
+ }
240
+ return state;
241
+ };
242
+ return Object.freeze({ append, hasMessageEvent, read, replay });
243
+ }
244
+ export { SessionEventError } from './errors.js';
245
+ export { SESSION_EVENTS_MIGRATION_SQL, SESSION_EVENTS_MIGRATIONS } from './schema.js';
@@ -0,0 +1,3 @@
1
+ import type { ComponentMigration } from '@spexcode/session-protocol';
2
+ export declare const SESSION_EVENTS_MIGRATION_SQL = "\nCREATE TABLE session_events (\n subject_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),\n event_seq INTEGER NOT NULL,\n event_id TEXT NOT NULL UNIQUE,\n event_type TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n ignorable INTEGER NOT NULL,\n payload BLOB NOT NULL,\n occurred_at_ms INTEGER NOT NULL,\n PRIMARY KEY (subject_session_id, event_seq),\n CHECK (event_seq >= 1),\n CHECK (length(event_id) = 32 AND event_id NOT GLOB '*[^0-9a-f]*'),\n CHECK (length(event_type) BETWEEN 1 AND 128),\n CHECK (event_type NOT GLOB '*[^0-9A-Za-z._:-]*'),\n CHECK (schema_version >= 1),\n CHECK (ignorable IN (0, 1)),\n CHECK (length(payload) <= 1048576),\n CHECK (occurred_at_ms >= 0)\n) STRICT;\n\nCREATE INDEX session_events_subject_history\n ON session_events (subject_session_id, event_seq);\n\nCREATE TRIGGER session_events_append_only_update\nBEFORE UPDATE ON session_events\nBEGIN\n SELECT RAISE(ABORT, 'session_events is append-only');\nEND;\n\nCREATE TRIGGER session_events_append_only_delete\nBEFORE DELETE ON session_events\nBEGIN\n SELECT RAISE(ABORT, 'session_events is append-only');\nEND;\n";
3
+ export declare const SESSION_EVENTS_MIGRATIONS: readonly ComponentMigration[];
package/dist/schema.js ADDED
@@ -0,0 +1,44 @@
1
+ export const SESSION_EVENTS_MIGRATION_SQL = `
2
+ CREATE TABLE session_events (
3
+ subject_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),
4
+ event_seq INTEGER NOT NULL,
5
+ event_id TEXT NOT NULL UNIQUE,
6
+ event_type TEXT NOT NULL,
7
+ schema_version INTEGER NOT NULL,
8
+ ignorable INTEGER NOT NULL,
9
+ payload BLOB NOT NULL,
10
+ occurred_at_ms INTEGER NOT NULL,
11
+ PRIMARY KEY (subject_session_id, event_seq),
12
+ CHECK (event_seq >= 1),
13
+ CHECK (length(event_id) = 32 AND event_id NOT GLOB '*[^0-9a-f]*'),
14
+ CHECK (length(event_type) BETWEEN 1 AND 128),
15
+ CHECK (event_type NOT GLOB '*[^0-9A-Za-z._:-]*'),
16
+ CHECK (schema_version >= 1),
17
+ CHECK (ignorable IN (0, 1)),
18
+ CHECK (length(payload) <= 1048576),
19
+ CHECK (occurred_at_ms >= 0)
20
+ ) STRICT;
21
+
22
+ CREATE INDEX session_events_subject_history
23
+ ON session_events (subject_session_id, event_seq);
24
+
25
+ CREATE TRIGGER session_events_append_only_update
26
+ BEFORE UPDATE ON session_events
27
+ BEGIN
28
+ SELECT RAISE(ABORT, 'session_events is append-only');
29
+ END;
30
+
31
+ CREATE TRIGGER session_events_append_only_delete
32
+ BEFORE DELETE ON session_events
33
+ BEGIN
34
+ SELECT RAISE(ABORT, 'session_events is append-only');
35
+ END;
36
+ `;
37
+ export const SESSION_EVENTS_MIGRATIONS = [
38
+ { version: 1, sql: SESSION_EVENTS_MIGRATION_SQL },
39
+ { version: 2, sql: `
40
+ CREATE INDEX session_events_message_lookup
41
+ ON session_events (subject_session_id, event_type)
42
+ WHERE event_type = 'session.message.sent.v1';
43
+ ` },
44
+ ];
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@spexcode/session-events",
3
+ "version": "0.6.8",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "description": "Append-only session facts and deterministic ordered replay inputs.",
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
+ }