@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.
@@ -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
+ }