@agentlip/kernel 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/migrations/0001_schema_v1.sql +156 -0
- package/migrations/0001_schema_v1_fts.sql +32 -0
- package/package.json +27 -0
- package/src/events.ts +365 -0
- package/src/index.ts +316 -0
- package/src/messageMutations.ts +523 -0
- package/src/queries.ts +418 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
-- Migration: 0001_schema_v1.sql
|
|
2
|
+
-- Initial schema for Agentlip Local Hub v1
|
|
3
|
+
-- From schema version: 0 (none)
|
|
4
|
+
-- To schema version: 1
|
|
5
|
+
|
|
6
|
+
BEGIN TRANSACTION;
|
|
7
|
+
|
|
8
|
+
-- Meta table: system metadata and versioning
|
|
9
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
10
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
11
|
+
value TEXT NOT NULL
|
|
12
|
+
) STRICT;
|
|
13
|
+
|
|
14
|
+
-- Required meta keys
|
|
15
|
+
-- - db_id: stable workspace identifier (UUIDv4-ish), generated once at init
|
|
16
|
+
-- - schema_version: current schema version
|
|
17
|
+
-- - created_at: workspace creation timestamp (ISO8601 UTC)
|
|
18
|
+
INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', '1');
|
|
19
|
+
INSERT OR IGNORE INTO meta (key, value)
|
|
20
|
+
VALUES ('created_at', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
|
21
|
+
INSERT OR IGNORE INTO meta (key, value) VALUES (
|
|
22
|
+
'db_id',
|
|
23
|
+
lower(hex(randomblob(4))) || '-' ||
|
|
24
|
+
lower(hex(randomblob(2))) || '-' ||
|
|
25
|
+
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
|
26
|
+
substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
|
27
|
+
lower(hex(randomblob(6)))
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
-- Channels: top-level conversation containers
|
|
31
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
32
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
33
|
+
name TEXT NOT NULL UNIQUE,
|
|
34
|
+
description TEXT,
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
CHECK (length(name) > 0 AND length(name) <= 100)
|
|
37
|
+
) STRICT;
|
|
38
|
+
|
|
39
|
+
-- Topics: first-class thread entities within channels
|
|
40
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
41
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
42
|
+
channel_id TEXT NOT NULL,
|
|
43
|
+
title TEXT NOT NULL,
|
|
44
|
+
created_at TEXT NOT NULL,
|
|
45
|
+
updated_at TEXT NOT NULL,
|
|
46
|
+
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
|
47
|
+
UNIQUE(channel_id, title),
|
|
48
|
+
CHECK (length(title) > 0 AND length(title) <= 200)
|
|
49
|
+
) STRICT;
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_topics_channel ON topics(channel_id, updated_at DESC);
|
|
52
|
+
|
|
53
|
+
-- Messages: content with stable identity, edit/delete via events
|
|
54
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
55
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
56
|
+
topic_id TEXT NOT NULL,
|
|
57
|
+
channel_id TEXT NOT NULL,
|
|
58
|
+
sender TEXT NOT NULL,
|
|
59
|
+
content_raw TEXT NOT NULL,
|
|
60
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
61
|
+
created_at TEXT NOT NULL,
|
|
62
|
+
edited_at TEXT,
|
|
63
|
+
deleted_at TEXT,
|
|
64
|
+
deleted_by TEXT,
|
|
65
|
+
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE,
|
|
66
|
+
CHECK (length(sender) > 0),
|
|
67
|
+
CHECK (length(content_raw) <= 65536),
|
|
68
|
+
CHECK (version >= 1)
|
|
69
|
+
) STRICT;
|
|
70
|
+
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_messages_topic ON messages(topic_id, id DESC);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at DESC);
|
|
74
|
+
|
|
75
|
+
-- Trigger: prevent hard deletes on messages (tombstone only)
|
|
76
|
+
CREATE TRIGGER IF NOT EXISTS prevent_message_delete
|
|
77
|
+
BEFORE DELETE ON messages
|
|
78
|
+
FOR EACH ROW
|
|
79
|
+
BEGIN
|
|
80
|
+
SELECT RAISE(ABORT, 'Hard deletes forbidden on messages; use tombstone');
|
|
81
|
+
END;
|
|
82
|
+
|
|
83
|
+
-- Events: immutable append-only event log (integration surface)
|
|
84
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
85
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
ts TEXT NOT NULL,
|
|
87
|
+
name TEXT NOT NULL,
|
|
88
|
+
scope_channel_id TEXT,
|
|
89
|
+
scope_topic_id TEXT,
|
|
90
|
+
scope_topic_id2 TEXT,
|
|
91
|
+
entity_type TEXT NOT NULL,
|
|
92
|
+
entity_id TEXT NOT NULL,
|
|
93
|
+
data_json TEXT NOT NULL,
|
|
94
|
+
CHECK (length(name) > 0)
|
|
95
|
+
) STRICT;
|
|
96
|
+
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_events_replay ON events(event_id);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_events_scope_channel ON events(scope_channel_id, event_id);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_events_scope_topic ON events(scope_topic_id, event_id);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_events_scope_topic2 ON events(scope_topic_id2, event_id);
|
|
101
|
+
|
|
102
|
+
-- Trigger: prevent updates on events (immutable)
|
|
103
|
+
CREATE TRIGGER IF NOT EXISTS prevent_event_mutation
|
|
104
|
+
BEFORE UPDATE ON events
|
|
105
|
+
FOR EACH ROW
|
|
106
|
+
BEGIN
|
|
107
|
+
SELECT RAISE(ABORT, 'Events are immutable');
|
|
108
|
+
END;
|
|
109
|
+
|
|
110
|
+
-- Trigger: prevent deletes on events (append-only)
|
|
111
|
+
CREATE TRIGGER IF NOT EXISTS prevent_event_delete
|
|
112
|
+
BEFORE DELETE ON events
|
|
113
|
+
FOR EACH ROW
|
|
114
|
+
BEGIN
|
|
115
|
+
SELECT RAISE(ABORT, 'Events are append-only');
|
|
116
|
+
END;
|
|
117
|
+
|
|
118
|
+
-- Topic attachments: structured grounding metadata with idempotency
|
|
119
|
+
CREATE TABLE IF NOT EXISTS topic_attachments (
|
|
120
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
121
|
+
topic_id TEXT NOT NULL,
|
|
122
|
+
kind TEXT NOT NULL,
|
|
123
|
+
key TEXT,
|
|
124
|
+
value_json TEXT NOT NULL,
|
|
125
|
+
dedupe_key TEXT NOT NULL,
|
|
126
|
+
source_message_id TEXT,
|
|
127
|
+
created_at TEXT NOT NULL,
|
|
128
|
+
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE,
|
|
129
|
+
FOREIGN KEY (source_message_id) REFERENCES messages(id) ON DELETE SET NULL,
|
|
130
|
+
CHECK (length(kind) > 0),
|
|
131
|
+
CHECK (length(dedupe_key) > 0),
|
|
132
|
+
CHECK (length(value_json) <= 16384)
|
|
133
|
+
) STRICT;
|
|
134
|
+
|
|
135
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_topic ON topic_attachments(topic_id, created_at DESC);
|
|
136
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_topic_attachments_dedupe
|
|
137
|
+
ON topic_attachments(topic_id, kind, COALESCE(key, ''), dedupe_key);
|
|
138
|
+
|
|
139
|
+
-- Enrichments: derived data (recomputable)
|
|
140
|
+
CREATE TABLE IF NOT EXISTS enrichments (
|
|
141
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
142
|
+
message_id TEXT NOT NULL,
|
|
143
|
+
kind TEXT NOT NULL,
|
|
144
|
+
span_start INTEGER NOT NULL,
|
|
145
|
+
span_end INTEGER NOT NULL,
|
|
146
|
+
data_json TEXT NOT NULL,
|
|
147
|
+
created_at TEXT NOT NULL,
|
|
148
|
+
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
149
|
+
CHECK (span_start >= 0),
|
|
150
|
+
CHECK (span_end > span_start),
|
|
151
|
+
CHECK (length(kind) > 0)
|
|
152
|
+
) STRICT;
|
|
153
|
+
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_enrichments_message ON enrichments(message_id, created_at DESC);
|
|
155
|
+
|
|
156
|
+
COMMIT;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Migration: 0001_schema_v1_fts.sql
|
|
2
|
+
-- Optional FTS5 virtual table for full-text search on message content
|
|
3
|
+
-- Applied opportunistically after schema_v1; non-fatal failure
|
|
4
|
+
|
|
5
|
+
BEGIN TRANSACTION;
|
|
6
|
+
|
|
7
|
+
-- FTS5 virtual table for message content search
|
|
8
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
9
|
+
content_raw,
|
|
10
|
+
content=messages,
|
|
11
|
+
content_rowid=rowid
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Trigger: sync FTS on insert
|
|
15
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages
|
|
16
|
+
BEGIN
|
|
17
|
+
INSERT INTO messages_fts(rowid, content_raw) VALUES (new.rowid, new.content_raw);
|
|
18
|
+
END;
|
|
19
|
+
|
|
20
|
+
-- Trigger: sync FTS on update
|
|
21
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages
|
|
22
|
+
BEGIN
|
|
23
|
+
UPDATE messages_fts SET content_raw = new.content_raw WHERE rowid = old.rowid;
|
|
24
|
+
END;
|
|
25
|
+
|
|
26
|
+
-- Trigger: sync FTS on delete
|
|
27
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages
|
|
28
|
+
BEGIN
|
|
29
|
+
DELETE FROM messages_fts WHERE rowid = old.rowid;
|
|
30
|
+
END;
|
|
31
|
+
|
|
32
|
+
COMMIT;
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentlip/kernel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SQLite schema, migrations, and query layer for Agentlip",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/phosphorco/agentlip",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/phosphorco/agentlip.git",
|
|
11
|
+
"directory": "packages/kernel"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"bun": ">=1.0.0"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/**/*.ts",
|
|
21
|
+
"migrations/**/*.sql",
|
|
22
|
+
"!src/**/*.test.ts"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event insertion and replay utilities for @agentlip/kernel
|
|
3
|
+
*
|
|
4
|
+
* Implements bd-16d.2.3 (canonical read queries for events) and bd-16d.2.9 (insertEvent helper)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "bun:sqlite";
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// Types
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface EventScopes {
|
|
14
|
+
channel_id?: string | null;
|
|
15
|
+
topic_id?: string | null;
|
|
16
|
+
topic_id2?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EventEntity {
|
|
20
|
+
type: string;
|
|
21
|
+
id: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InsertEventOptions {
|
|
25
|
+
db: Database;
|
|
26
|
+
name: string;
|
|
27
|
+
scopes: EventScopes;
|
|
28
|
+
entity: EventEntity;
|
|
29
|
+
data: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EventRow {
|
|
33
|
+
event_id: number;
|
|
34
|
+
ts: string;
|
|
35
|
+
name: string;
|
|
36
|
+
scope_channel_id: string | null;
|
|
37
|
+
scope_topic_id: string | null;
|
|
38
|
+
scope_topic_id2: string | null;
|
|
39
|
+
entity_type: string;
|
|
40
|
+
entity_id: string;
|
|
41
|
+
data_json: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ParsedEvent {
|
|
45
|
+
event_id: number;
|
|
46
|
+
ts: string;
|
|
47
|
+
name: string;
|
|
48
|
+
scope: EventScopes;
|
|
49
|
+
entity: EventEntity;
|
|
50
|
+
data: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReplayEventsOptions {
|
|
54
|
+
db: Database;
|
|
55
|
+
afterEventId: number;
|
|
56
|
+
replayUntil: number;
|
|
57
|
+
channelIds?: string[];
|
|
58
|
+
topicIds?: string[];
|
|
59
|
+
limit?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Event Scope Catalog
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scope requirements for known v1 events.
|
|
68
|
+
* Unknown event names are allowed (for extensibility) and skip scope validation.
|
|
69
|
+
*
|
|
70
|
+
* Implements bd-16d.2.10 (scope correctness) and bd-16d.2.11 (dev assertions).
|
|
71
|
+
*/
|
|
72
|
+
interface ScopeRequirements {
|
|
73
|
+
channel_id: boolean; // true if channel_id is required
|
|
74
|
+
topic_id: boolean; // true if topic_id is required
|
|
75
|
+
topic_id2: boolean; // true if topic_id2 is required
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const EVENT_SCOPE_CATALOG: Record<string, ScopeRequirements> = {
|
|
79
|
+
// Channel events: require channel_id only
|
|
80
|
+
"channel.created": { channel_id: true, topic_id: false, topic_id2: false },
|
|
81
|
+
|
|
82
|
+
// Topic events: require channel_id + topic_id
|
|
83
|
+
"topic.created": { channel_id: true, topic_id: true, topic_id2: false },
|
|
84
|
+
"topic.renamed": { channel_id: true, topic_id: true, topic_id2: false },
|
|
85
|
+
"topic.attachment_added": { channel_id: true, topic_id: true, topic_id2: false },
|
|
86
|
+
|
|
87
|
+
// Message events: require channel_id + topic_id (except moved_topic)
|
|
88
|
+
"message.created": { channel_id: true, topic_id: true, topic_id2: false },
|
|
89
|
+
"message.edited": { channel_id: true, topic_id: true, topic_id2: false },
|
|
90
|
+
"message.deleted": { channel_id: true, topic_id: true, topic_id2: false },
|
|
91
|
+
"message.enriched": { channel_id: true, topic_id: true, topic_id2: false },
|
|
92
|
+
|
|
93
|
+
// Message move: requires channel_id + topic_id (old) + topic_id2 (new)
|
|
94
|
+
"message.moved_topic": { channel_id: true, topic_id: true, topic_id2: true },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate event scopes against catalog requirements.
|
|
99
|
+
*
|
|
100
|
+
* @param name - Event name
|
|
101
|
+
* @param scopes - Event scopes to validate
|
|
102
|
+
* @throws Error if required scopes are missing or invalid for known event types
|
|
103
|
+
*/
|
|
104
|
+
function validateEventScopes(name: string, scopes: EventScopes): void {
|
|
105
|
+
const requirements = EVENT_SCOPE_CATALOG[name];
|
|
106
|
+
|
|
107
|
+
// Unknown event types are allowed (skip validation)
|
|
108
|
+
if (!requirements) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Helper to check if a scope value is valid (present and non-empty)
|
|
113
|
+
const isValidScope = (value: string | null | undefined): value is string => {
|
|
114
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Check each required scope
|
|
118
|
+
if (requirements.channel_id && !isValidScope(scopes.channel_id)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Event '${name}' requires scope.channel_id but it is missing or empty`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (requirements.topic_id && !isValidScope(scopes.topic_id)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Event '${name}' requires scope.topic_id but it is missing or empty`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (requirements.topic_id2 && !isValidScope(scopes.topic_id2)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Event '${name}' requires scope.topic_id2 but it is missing or empty`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// insertEvent Helper
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Insert a new event into the events table.
|
|
143
|
+
*
|
|
144
|
+
* Single entry point for all event insertions. Validates inputs and serializes
|
|
145
|
+
* data deterministically. Never mutates existing events (schema triggers prevent).
|
|
146
|
+
*
|
|
147
|
+
* Enforces scope correctness per EVENT_SCOPE_CATALOG (bd-16d.2.10 + bd-16d.2.11).
|
|
148
|
+
* Unknown event names are allowed and skip scope validation.
|
|
149
|
+
*
|
|
150
|
+
* @param options - Event insertion options
|
|
151
|
+
* @returns The inserted event_id (monotonically increasing)
|
|
152
|
+
* @throws Error if name is empty, entity fields empty, data is not a plain object, or scopes are invalid
|
|
153
|
+
*/
|
|
154
|
+
export function insertEvent(options: InsertEventOptions): number {
|
|
155
|
+
const { db, name, scopes, entity, data } = options;
|
|
156
|
+
|
|
157
|
+
// Validate name
|
|
158
|
+
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
|
159
|
+
throw new Error("Event name must be a non-empty string");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Validate scopes (bd-16d.2.10 + bd-16d.2.11)
|
|
163
|
+
validateEventScopes(name, scopes);
|
|
164
|
+
|
|
165
|
+
// Validate entity
|
|
166
|
+
if (!entity.type || typeof entity.type !== "string" || entity.type.trim().length === 0) {
|
|
167
|
+
throw new Error("Entity type must be a non-empty string");
|
|
168
|
+
}
|
|
169
|
+
if (!entity.id || typeof entity.id !== "string" || entity.id.trim().length === 0) {
|
|
170
|
+
throw new Error("Entity id must be a non-empty string");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate data is a plain object (not null, array, or primitive)
|
|
174
|
+
if (data === null || data === undefined) {
|
|
175
|
+
throw new Error("Event data must be an object, got null/undefined");
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(data)) {
|
|
178
|
+
throw new Error("Event data must be an object, not an array");
|
|
179
|
+
}
|
|
180
|
+
if (typeof data !== "object") {
|
|
181
|
+
throw new Error(`Event data must be an object, got ${typeof data}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Serialize data deterministically (JSON.stringify is deterministic for same input structure)
|
|
185
|
+
const dataJson = JSON.stringify(data);
|
|
186
|
+
|
|
187
|
+
// Generate timestamp
|
|
188
|
+
const ts = new Date().toISOString();
|
|
189
|
+
|
|
190
|
+
// Insert event
|
|
191
|
+
const stmt = db.prepare<
|
|
192
|
+
{ event_id: number },
|
|
193
|
+
[string, string, string | null, string | null, string | null, string, string, string]
|
|
194
|
+
>(`
|
|
195
|
+
INSERT INTO events (ts, name, scope_channel_id, scope_topic_id, scope_topic_id2, entity_type, entity_id, data_json)
|
|
196
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
197
|
+
RETURNING event_id
|
|
198
|
+
`);
|
|
199
|
+
|
|
200
|
+
const result = stmt.get(
|
|
201
|
+
ts,
|
|
202
|
+
name,
|
|
203
|
+
scopes.channel_id ?? null,
|
|
204
|
+
scopes.topic_id ?? null,
|
|
205
|
+
scopes.topic_id2 ?? null,
|
|
206
|
+
entity.type,
|
|
207
|
+
entity.id,
|
|
208
|
+
dataJson
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (!result) {
|
|
212
|
+
throw new Error("Failed to insert event: no event_id returned");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return result.event_id;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
// Event Replay Queries
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get the latest event_id from the events table.
|
|
224
|
+
*
|
|
225
|
+
* Used for WS handshake to establish replay_until boundary.
|
|
226
|
+
*
|
|
227
|
+
* @param db - Database instance
|
|
228
|
+
* @returns The maximum event_id, or 0 if no events exist
|
|
229
|
+
*/
|
|
230
|
+
export function getLatestEventId(db: Database): number {
|
|
231
|
+
const row = db
|
|
232
|
+
.query<{ max_id: number | null }, []>("SELECT MAX(event_id) as max_id FROM events")
|
|
233
|
+
.get();
|
|
234
|
+
|
|
235
|
+
return row?.max_id ?? 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parse a raw EventRow into a ParsedEvent with structured scope/entity/data.
|
|
240
|
+
*/
|
|
241
|
+
function parseEventRow(row: EventRow): ParsedEvent {
|
|
242
|
+
return {
|
|
243
|
+
event_id: row.event_id,
|
|
244
|
+
ts: row.ts,
|
|
245
|
+
name: row.name,
|
|
246
|
+
scope: {
|
|
247
|
+
channel_id: row.scope_channel_id,
|
|
248
|
+
topic_id: row.scope_topic_id,
|
|
249
|
+
topic_id2: row.scope_topic_id2,
|
|
250
|
+
},
|
|
251
|
+
entity: {
|
|
252
|
+
type: row.entity_type,
|
|
253
|
+
id: row.entity_id,
|
|
254
|
+
},
|
|
255
|
+
data: JSON.parse(row.data_json),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Replay events matching the specified criteria.
|
|
261
|
+
*
|
|
262
|
+
* Implements the plan's replay query:
|
|
263
|
+
* WHERE event_id > after AND event_id <= replay_until
|
|
264
|
+
* AND (scopes match) ORDER BY event_id ASC LIMIT ...
|
|
265
|
+
*
|
|
266
|
+
* Scope matching logic:
|
|
267
|
+
* - If channelIds provided: events where scope_channel_id IN channelIds
|
|
268
|
+
* - If topicIds provided: events where scope_topic_id IN topicIds OR scope_topic_id2 IN topicIds
|
|
269
|
+
* - Both can be combined (OR: matches channel OR topic)
|
|
270
|
+
* - If neither provided: returns all events in range
|
|
271
|
+
*
|
|
272
|
+
* @param options - Replay options
|
|
273
|
+
* @returns Array of parsed events in ascending event_id order
|
|
274
|
+
*/
|
|
275
|
+
export function replayEvents(options: ReplayEventsOptions): ParsedEvent[] {
|
|
276
|
+
const { db, afterEventId, replayUntil, channelIds, topicIds, limit = 1000 } = options;
|
|
277
|
+
|
|
278
|
+
// Validate boundaries
|
|
279
|
+
if (afterEventId < 0) {
|
|
280
|
+
throw new Error("afterEventId must be >= 0");
|
|
281
|
+
}
|
|
282
|
+
if (replayUntil < afterEventId) {
|
|
283
|
+
throw new Error("replayUntil must be >= afterEventId");
|
|
284
|
+
}
|
|
285
|
+
if (limit <= 0) {
|
|
286
|
+
throw new Error("limit must be > 0");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Build query based on scope filters
|
|
290
|
+
const hasChannelFilter = channelIds && channelIds.length > 0;
|
|
291
|
+
const hasTopicFilter = topicIds && topicIds.length > 0;
|
|
292
|
+
|
|
293
|
+
let sql = `
|
|
294
|
+
SELECT event_id, ts, name, scope_channel_id, scope_topic_id, scope_topic_id2,
|
|
295
|
+
entity_type, entity_id, data_json
|
|
296
|
+
FROM events
|
|
297
|
+
WHERE event_id > ? AND event_id <= ?
|
|
298
|
+
`;
|
|
299
|
+
|
|
300
|
+
const params: (number | string)[] = [afterEventId, replayUntil];
|
|
301
|
+
|
|
302
|
+
if (hasChannelFilter || hasTopicFilter) {
|
|
303
|
+
const scopeConditions: string[] = [];
|
|
304
|
+
|
|
305
|
+
if (hasChannelFilter) {
|
|
306
|
+
const placeholders = channelIds.map(() => "?").join(", ");
|
|
307
|
+
scopeConditions.push(`scope_channel_id IN (${placeholders})`);
|
|
308
|
+
params.push(...channelIds);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (hasTopicFilter) {
|
|
312
|
+
const placeholders = topicIds.map(() => "?").join(", ");
|
|
313
|
+
scopeConditions.push(`(scope_topic_id IN (${placeholders}) OR scope_topic_id2 IN (${placeholders}))`);
|
|
314
|
+
params.push(...topicIds, ...topicIds);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
sql += ` AND (${scopeConditions.join(" OR ")})`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
sql += ` ORDER BY event_id ASC LIMIT ?`;
|
|
321
|
+
params.push(limit);
|
|
322
|
+
|
|
323
|
+
const rows = db.query<EventRow, (number | string)[]>(sql).all(...params);
|
|
324
|
+
|
|
325
|
+
return rows.map(parseEventRow);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get a single event by its ID.
|
|
330
|
+
*
|
|
331
|
+
* @param db - Database instance
|
|
332
|
+
* @param eventId - Event ID to retrieve
|
|
333
|
+
* @returns Parsed event or null if not found
|
|
334
|
+
*/
|
|
335
|
+
export function getEventById(db: Database, eventId: number): ParsedEvent | null {
|
|
336
|
+
const row = db
|
|
337
|
+
.query<EventRow, [number]>(`
|
|
338
|
+
SELECT event_id, ts, name, scope_channel_id, scope_topic_id, scope_topic_id2,
|
|
339
|
+
entity_type, entity_id, data_json
|
|
340
|
+
FROM events
|
|
341
|
+
WHERE event_id = ?
|
|
342
|
+
`)
|
|
343
|
+
.get(eventId);
|
|
344
|
+
|
|
345
|
+
return row ? parseEventRow(row) : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Count events in a range (useful for pagination info).
|
|
350
|
+
*
|
|
351
|
+
* @param db - Database instance
|
|
352
|
+
* @param afterEventId - Start boundary (exclusive)
|
|
353
|
+
* @param replayUntil - End boundary (inclusive)
|
|
354
|
+
* @returns Number of events in range
|
|
355
|
+
*/
|
|
356
|
+
export function countEventsInRange(db: Database, afterEventId: number, replayUntil: number): number {
|
|
357
|
+
const row = db
|
|
358
|
+
.query<{ count: number }, [number, number]>(`
|
|
359
|
+
SELECT COUNT(*) as count FROM events
|
|
360
|
+
WHERE event_id > ? AND event_id <= ?
|
|
361
|
+
`)
|
|
362
|
+
.get(afterEventId, replayUntil);
|
|
363
|
+
|
|
364
|
+
return row?.count ?? 0;
|
|
365
|
+
}
|