@holon-run/agentinbox 0.1.0 → 0.1.1

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/src/store.js CHANGED
@@ -8,7 +8,8 @@ const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const sql_js_1 = __importDefault(require("sql.js"));
10
10
  const util_1 = require("./util");
11
- const SCHEMA_VERSION = 11;
11
+ const LEGACY_SCHEMA_VERSION = 12;
12
+ const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
12
13
  function parseJson(value) {
13
14
  if (!value) {
14
15
  return {};
@@ -49,254 +50,89 @@ class AgentInboxStore {
49
50
  this.persist();
50
51
  }
51
52
  migrate() {
53
+ const migrations = this.loadSqlMigrations();
54
+ const hasMigrationTable = this.tableExists(DRIZZLE_MIGRATIONS_TABLE);
55
+ this.ensureDrizzleMigrationsTable();
56
+ const applied = this.listAppliedMigrationTags();
52
57
  const userVersion = this.userVersion();
53
- if (userVersion === 0) {
54
- this.dropKnownTables();
55
- this.createCurrentSchema();
56
- this.setUserVersion(SCHEMA_VERSION);
57
- return;
58
+ if (applied.size === 0 && !hasMigrationTable && this.tableExists("sources")) {
59
+ if (userVersion !== 0 && userVersion !== LEGACY_SCHEMA_VERSION) {
60
+ throw new Error(`unsupported legacy database schema version ${userVersion} at ${this.dbPath}`);
61
+ }
62
+ this.recordAppliedMigration(migrations[0].tag);
63
+ applied.add(migrations[0].tag);
64
+ }
65
+ const pending = migrations.filter((migration) => !applied.has(migration.tag));
66
+ if (pending.length > 0) {
67
+ if (this.shouldBackupBeforeMigration()) {
68
+ this.backupDatabase();
69
+ }
70
+ for (const migration of pending) {
71
+ this.applyMigration(migration);
72
+ }
58
73
  }
59
- if (userVersion !== SCHEMA_VERSION) {
60
- throw new Error(`unsupported database schema version ${userVersion}; delete ${this.dbPath} to recreate`);
74
+ this.setUserVersion(migrations.length);
75
+ }
76
+ loadSqlMigrations() {
77
+ const migrationsDir = this.resolveMigrationsDir();
78
+ const files = node_fs_1.default
79
+ .readdirSync(migrationsDir)
80
+ .filter((name) => name.endsWith(".sql"))
81
+ .sort();
82
+ if (files.length === 0) {
83
+ throw new Error(`no SQL migrations found in ${migrationsDir}`);
61
84
  }
62
- this.createCurrentSchema();
63
- }
64
- dropKnownTables() {
65
- const tables = [
66
- "consumer_commits",
67
- "consumers",
68
- "stream_events",
69
- "streams",
70
- "deliveries",
71
- "activations",
72
- "inbox_items",
73
- "activation_dispatch_states",
74
- "activation_targets",
75
- "subscriptions",
76
- "inboxes",
77
- "agents",
78
- "sources",
79
- "terminal_dispatch_states",
80
- "terminal_targets",
81
- "interests",
85
+ return files.map((name) => ({
86
+ tag: name.replace(/\.sql$/, ""),
87
+ sql: node_fs_1.default.readFileSync(node_path_1.default.join(migrationsDir, name), "utf8"),
88
+ }));
89
+ }
90
+ resolveMigrationsDir() {
91
+ const candidates = [
92
+ node_path_1.default.resolve(__dirname, "../drizzle/migrations"),
93
+ node_path_1.default.resolve(__dirname, "../../drizzle/migrations"),
94
+ node_path_1.default.resolve(process.cwd(), "drizzle/migrations"),
82
95
  ];
83
- this.inTransaction(() => {
84
- for (const table of tables) {
85
- if (this.tableExists(table)) {
86
- this.db.exec(`drop table ${table};`);
87
- }
96
+ for (const candidate of candidates) {
97
+ if (node_fs_1.default.existsSync(candidate)) {
98
+ return candidate;
88
99
  }
89
- });
100
+ }
101
+ throw new Error(`cannot locate drizzle migrations directory from ${__dirname}`);
90
102
  }
91
- createCurrentSchema() {
103
+ ensureDrizzleMigrationsTable() {
92
104
  this.db.exec(`
93
- create table if not exists sources (
94
- source_id text primary key,
95
- source_type text not null,
96
- source_key text not null,
97
- config_ref text,
98
- config_json text not null,
99
- status text not null,
100
- checkpoint text,
101
- created_at text not null,
102
- updated_at text not null,
103
- unique(source_type, source_key)
104
- );
105
-
106
- create table if not exists agents (
107
- agent_id text primary key,
108
- status text not null,
109
- offline_since text,
110
- runtime_kind text not null,
111
- runtime_session_id text,
112
- created_at text not null,
113
- updated_at text not null,
114
- last_seen_at text not null
115
- );
116
-
117
- create table if not exists inboxes (
118
- inbox_id text primary key,
119
- owner_agent_id text not null unique,
120
- created_at text not null
121
- );
122
-
123
- create table if not exists subscriptions (
124
- subscription_id text primary key,
125
- agent_id text not null,
126
- source_id text not null,
127
- filter_json text not null,
128
- lifecycle_mode text not null,
129
- expires_at text,
130
- start_policy text not null,
131
- start_offset integer,
132
- start_time text,
133
- created_at text not null
134
- );
135
-
136
- create table if not exists activation_targets (
137
- target_id text primary key,
138
- agent_id text not null,
139
- kind text not null,
140
- status text not null,
141
- offline_since text,
142
- consecutive_failures integer not null,
143
- last_delivered_at text,
144
- last_error text,
145
- mode text not null,
146
- notify_lease_ms integer not null,
147
- url text,
148
- runtime_kind text,
149
- runtime_session_id text,
150
- backend text,
151
- tmux_pane_id text,
152
- tty text,
153
- term_program text,
154
- iterm_session_id text,
155
- created_at text not null,
156
- updated_at text not null,
157
- last_seen_at text not null
158
- );
159
-
160
- create table if not exists activation_dispatch_states (
161
- agent_id text not null,
162
- target_id text not null,
163
- status text not null,
164
- lease_expires_at text,
165
- pending_new_item_count integer not null,
166
- pending_summary text,
167
- pending_subscription_ids_json text not null,
168
- pending_source_ids_json text not null,
169
- updated_at text not null,
170
- primary key (agent_id, target_id)
105
+ create table if not exists ${DRIZZLE_MIGRATIONS_TABLE} (
106
+ id integer primary key autoincrement,
107
+ tag text not null unique,
108
+ applied_at text not null
171
109
  );
172
-
173
- create table if not exists inbox_items (
174
- item_id text primary key,
175
- source_id text not null,
176
- source_native_id text not null,
177
- event_variant text not null,
178
- inbox_id text not null,
179
- occurred_at text not null,
180
- metadata_json text not null,
181
- raw_payload_json text not null,
182
- delivery_handle_json text,
183
- acked_at text,
184
- unique(source_id, source_native_id, event_variant, inbox_id)
185
- );
186
-
187
- create table if not exists activations (
188
- activation_id text primary key,
189
- kind text not null,
190
- agent_id text not null,
191
- inbox_id text not null,
192
- target_id text not null,
193
- target_kind text not null,
194
- subscription_ids_json text not null,
195
- source_ids_json text not null,
196
- new_item_count integer not null,
197
- summary text not null,
198
- items_json text,
199
- created_at text not null,
200
- delivered_at text
201
- );
202
-
203
- create table if not exists deliveries (
204
- delivery_id text primary key,
205
- provider text not null,
206
- surface text not null,
207
- target_ref text not null,
208
- thread_ref text,
209
- reply_mode text,
210
- kind text not null,
211
- payload_json text not null,
212
- status text not null,
213
- created_at text not null
214
- );
215
-
216
- create table if not exists streams (
217
- stream_id text primary key,
218
- source_id text not null unique,
219
- stream_key text not null unique,
220
- backend text not null,
221
- created_at text not null
222
- );
223
-
224
- create table if not exists stream_events (
225
- offset integer primary key autoincrement,
226
- stream_event_id text not null unique,
227
- stream_id text not null,
228
- source_id text not null,
229
- source_native_id text not null,
230
- event_variant text not null,
231
- occurred_at text not null,
232
- metadata_json text not null,
233
- raw_payload_json text not null,
234
- delivery_handle_json text,
235
- created_at text not null,
236
- unique(stream_id, source_native_id, event_variant)
237
- );
238
-
239
- create table if not exists consumers (
240
- consumer_id text primary key,
241
- stream_id text not null,
242
- subscription_id text not null unique,
243
- consumer_key text not null unique,
244
- start_policy text not null,
245
- start_offset integer,
246
- start_time text,
247
- next_offset integer not null,
248
- created_at text not null,
249
- updated_at text not null
250
- );
251
-
252
- create table if not exists consumer_commits (
253
- commit_id text primary key,
254
- consumer_id text not null,
255
- stream_id text not null,
256
- committed_offset integer not null,
257
- committed_at text not null
258
- );
259
-
260
- create unique index if not exists idx_activation_targets_terminal_tmux
261
- on activation_targets(tmux_pane_id)
262
- where kind = 'terminal' and tmux_pane_id is not null;
263
-
264
- create unique index if not exists idx_activation_targets_terminal_iterm
265
- on activation_targets(iterm_session_id)
266
- where kind = 'terminal' and iterm_session_id is not null;
267
-
268
- create unique index if not exists idx_activation_targets_runtime_session
269
- on activation_targets(runtime_kind, runtime_session_id)
270
- where kind = 'terminal' and runtime_session_id is not null;
271
-
272
- create index if not exists idx_activation_dispatch_states_target
273
- on activation_dispatch_states(target_id, lease_expires_at);
274
-
275
- create index if not exists idx_inbox_items_acked_at
276
- on inbox_items(acked_at);
277
-
278
- create index if not exists idx_inbox_items_inbox_acked_at
279
- on inbox_items(inbox_id, acked_at);
280
-
281
- create index if not exists idx_agents_status_offline
282
- on agents(status, offline_since);
283
-
284
- create index if not exists idx_activation_targets_agent_status
285
- on activation_targets(agent_id, status);
286
-
287
- create index if not exists idx_stream_events_stream_offset
288
- on stream_events(stream_id, offset);
289
-
290
- create index if not exists idx_stream_events_stream_occurred_at
291
- on stream_events(stream_id, occurred_at);
292
-
293
- create index if not exists idx_consumers_stream
294
- on consumers(stream_id);
295
-
296
- create index if not exists idx_consumer_commits_consumer
297
- on consumer_commits(consumer_id, committed_offset);
298
110
  `);
299
111
  }
112
+ listAppliedMigrationTags() {
113
+ const rows = this.getAll(`select tag from ${DRIZZLE_MIGRATIONS_TABLE} order by id asc`);
114
+ return new Set(rows.map((row) => String(row.tag)));
115
+ }
116
+ applyMigration(migration) {
117
+ this.inTransaction(() => {
118
+ this.db.exec(migration.sql);
119
+ this.recordAppliedMigration(migration.tag);
120
+ });
121
+ }
122
+ recordAppliedMigration(tag) {
123
+ this.db.run(`insert or ignore into ${DRIZZLE_MIGRATIONS_TABLE} (tag, applied_at) values (?, ?)`, [tag, (0, util_1.nowIso)()]);
124
+ }
125
+ shouldBackupBeforeMigration() {
126
+ if (!node_fs_1.default.existsSync(this.dbPath)) {
127
+ return false;
128
+ }
129
+ return this.tableExists("sources");
130
+ }
131
+ backupDatabase() {
132
+ const safeStamp = (0, util_1.nowIso)().replace(/[:.]/g, "-");
133
+ const backupPath = `${this.dbPath}.backup-${safeStamp}`;
134
+ node_fs_1.default.copyFileSync(this.dbPath, backupPath);
135
+ }
300
136
  persist() {
301
137
  const data = this.db.export();
302
138
  node_fs_1.default.writeFileSync(this.dbPath, Buffer.from(data));
@@ -327,9 +163,7 @@ class AgentInboxStore {
327
163
  return Boolean(row);
328
164
  }
329
165
  getSourceByKey(sourceType, sourceKey) {
330
- const row = sourceType === "local_event"
331
- ? this.getOne("select * from sources where source_type in (?, ?) and source_key = ? order by case when source_type = ? then 0 else 1 end limit 1", ["local_event", "custom", sourceKey, "local_event"])
332
- : this.getOne("select * from sources where source_type = ? and source_key = ?", [sourceType, sourceKey]);
166
+ const row = this.getOne("select * from sources where source_type = ? and source_key = ?", [sourceType, sourceKey]);
333
167
  return row ? this.mapSource(row) : null;
334
168
  }
335
169
  getSource(sourceId) {
@@ -359,6 +193,24 @@ class AgentInboxStore {
359
193
  const rows = this.getAll("select * from sources order by created_at asc");
360
194
  return rows.map((row) => this.mapSource(row));
361
195
  }
196
+ updateSourceDefinition(sourceId, input) {
197
+ const current = this.getSource(sourceId);
198
+ if (!current) {
199
+ throw new Error(`unknown source: ${sourceId}`);
200
+ }
201
+ this.db.run(`
202
+ update sources
203
+ set config_ref = ?, config_json = ?, updated_at = ?
204
+ where source_id = ?
205
+ `, [
206
+ Object.prototype.hasOwnProperty.call(input, "configRef") ? input.configRef ?? null : current.configRef ?? null,
207
+ JSON.stringify(Object.prototype.hasOwnProperty.call(input, "config") ? input.config ?? {} : current.config ?? {}),
208
+ (0, util_1.nowIso)(),
209
+ sourceId,
210
+ ]);
211
+ this.persist();
212
+ return this.getSource(sourceId);
213
+ }
362
214
  updateSourceRuntime(sourceId, input) {
363
215
  const current = this.getSource(sourceId);
364
216
  if (!current) {
@@ -369,13 +221,35 @@ class AgentInboxStore {
369
221
  set status = ?, checkpoint = ?, updated_at = ?
370
222
  where source_id = ?
371
223
  `, [
372
- input.status ?? current.status,
373
- input.checkpoint ?? current.checkpoint ?? null,
224
+ Object.prototype.hasOwnProperty.call(input, "status") ? input.status ?? current.status : current.status,
225
+ Object.prototype.hasOwnProperty.call(input, "checkpoint") ? input.checkpoint ?? null : current.checkpoint ?? null,
374
226
  (0, util_1.nowIso)(),
375
227
  sourceId,
376
228
  ]);
377
229
  this.persist();
378
230
  }
231
+ deleteSource(sourceId) {
232
+ const source = this.getSource(sourceId);
233
+ if (!source) {
234
+ return null;
235
+ }
236
+ this.inTransaction(() => {
237
+ const stream = this.getStreamBySourceId(sourceId);
238
+ if (stream) {
239
+ const consumers = this.getAll("select consumer_id from consumers where stream_id = ?", [stream.streamId]);
240
+ for (const row of consumers) {
241
+ const consumerId = String(row.consumer_id);
242
+ this.db.run("delete from consumer_commits where consumer_id = ?", [consumerId]);
243
+ }
244
+ this.db.run("delete from consumers where stream_id = ?", [stream.streamId]);
245
+ this.db.run("delete from stream_events where stream_id = ?", [stream.streamId]);
246
+ this.db.run("delete from streams where stream_id = ?", [stream.streamId]);
247
+ }
248
+ this.db.run("delete from sources where source_id = ?", [sourceId]);
249
+ });
250
+ this.persist();
251
+ return source;
252
+ }
379
253
  getAgent(agentId) {
380
254
  const row = this.getOne("select * from agents where agent_id = ?", [agentId]);
381
255
  return row ? this.mapAgent(row) : null;
@@ -1073,12 +947,9 @@ class AgentInboxStore {
1073
947
  }
1074
948
  }
1075
949
  mapSource(row) {
1076
- const sourceType = String(row.source_type) === "custom"
1077
- ? "local_event"
1078
- : row.source_type;
1079
950
  return {
1080
951
  sourceId: String(row.source_id),
1081
- sourceType,
952
+ sourceType: row.source_type,
1082
953
  sourceKey: String(row.source_key),
1083
954
  configRef: row.config_ref ? String(row.config_ref) : null,
1084
955
  config: parseJson(row.config_json),
package/dist/src/util.js CHANGED
@@ -15,13 +15,29 @@ function nowIso() {
15
15
  function generateId(prefix) {
16
16
  return `${prefix}_${node_crypto_1.default.randomUUID()}`;
17
17
  }
18
- function parseJsonArg(raw) {
18
+ function parseJsonArg(raw, source = "JSON argument", options) {
19
19
  if (!raw) {
20
+ if (options?.requireNonEmptyObject) {
21
+ throw new Error(`invalid ${source}: expected a non-empty JSON object`);
22
+ }
20
23
  return {};
21
24
  }
22
- const parsed = JSON.parse(raw);
25
+ if (options?.requireNonEmptyObject && raw.trim() === "") {
26
+ throw new Error(`invalid ${source}: expected a non-empty JSON object`);
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(raw);
31
+ }
32
+ catch (error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ throw new Error(`invalid ${source}: ${message}`);
35
+ }
23
36
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
24
- throw new Error("expected a JSON object");
37
+ throw new Error(`expected ${source} to be a JSON object`);
38
+ }
39
+ if (options?.requireNonEmptyObject && Object.keys(parsed).length === 0) {
40
+ throw new Error(`invalid ${source}: expected a non-empty JSON object`);
25
41
  }
26
42
  return parsed;
27
43
  }
@@ -0,0 +1,206 @@
1
+ create table if not exists sources (
2
+ source_id text primary key,
3
+ source_type text not null,
4
+ source_key text not null,
5
+ config_ref text,
6
+ config_json text not null,
7
+ status text not null,
8
+ checkpoint text,
9
+ created_at text not null,
10
+ updated_at text not null,
11
+ unique(source_type, source_key)
12
+ );
13
+
14
+ create table if not exists agents (
15
+ agent_id text primary key,
16
+ status text not null,
17
+ offline_since text,
18
+ runtime_kind text not null,
19
+ runtime_session_id text,
20
+ created_at text not null,
21
+ updated_at text not null,
22
+ last_seen_at text not null
23
+ );
24
+
25
+ create table if not exists inboxes (
26
+ inbox_id text primary key,
27
+ owner_agent_id text not null unique,
28
+ created_at text not null
29
+ );
30
+
31
+ create table if not exists subscriptions (
32
+ subscription_id text primary key,
33
+ agent_id text not null,
34
+ source_id text not null,
35
+ filter_json text not null,
36
+ lifecycle_mode text not null,
37
+ expires_at text,
38
+ start_policy text not null,
39
+ start_offset integer,
40
+ start_time text,
41
+ created_at text not null
42
+ );
43
+
44
+ create table if not exists activation_targets (
45
+ target_id text primary key,
46
+ agent_id text not null,
47
+ kind text not null,
48
+ status text not null,
49
+ offline_since text,
50
+ consecutive_failures integer not null,
51
+ last_delivered_at text,
52
+ last_error text,
53
+ mode text not null,
54
+ notify_lease_ms integer not null,
55
+ url text,
56
+ runtime_kind text,
57
+ runtime_session_id text,
58
+ backend text,
59
+ tmux_pane_id text,
60
+ tty text,
61
+ term_program text,
62
+ iterm_session_id text,
63
+ created_at text not null,
64
+ updated_at text not null,
65
+ last_seen_at text not null
66
+ );
67
+
68
+ create table if not exists activation_dispatch_states (
69
+ agent_id text not null,
70
+ target_id text not null,
71
+ status text not null,
72
+ lease_expires_at text,
73
+ pending_new_item_count integer not null,
74
+ pending_summary text,
75
+ pending_subscription_ids_json text not null,
76
+ pending_source_ids_json text not null,
77
+ updated_at text not null,
78
+ primary key (agent_id, target_id)
79
+ );
80
+
81
+ create table if not exists inbox_items (
82
+ item_id text primary key,
83
+ source_id text not null,
84
+ source_native_id text not null,
85
+ event_variant text not null,
86
+ inbox_id text not null,
87
+ occurred_at text not null,
88
+ metadata_json text not null,
89
+ raw_payload_json text not null,
90
+ delivery_handle_json text,
91
+ acked_at text,
92
+ unique(source_id, source_native_id, event_variant, inbox_id)
93
+ );
94
+
95
+ create table if not exists activations (
96
+ activation_id text primary key,
97
+ kind text not null,
98
+ agent_id text not null,
99
+ inbox_id text not null,
100
+ target_id text not null,
101
+ target_kind text not null,
102
+ subscription_ids_json text not null,
103
+ source_ids_json text not null,
104
+ new_item_count integer not null,
105
+ summary text not null,
106
+ items_json text,
107
+ created_at text not null,
108
+ delivered_at text
109
+ );
110
+
111
+ create table if not exists deliveries (
112
+ delivery_id text primary key,
113
+ provider text not null,
114
+ surface text not null,
115
+ target_ref text not null,
116
+ thread_ref text,
117
+ reply_mode text,
118
+ kind text not null,
119
+ payload_json text not null,
120
+ status text not null,
121
+ created_at text not null
122
+ );
123
+
124
+ create table if not exists streams (
125
+ stream_id text primary key,
126
+ source_id text not null unique,
127
+ stream_key text not null unique,
128
+ backend text not null,
129
+ created_at text not null
130
+ );
131
+
132
+ create table if not exists stream_events (
133
+ offset integer primary key autoincrement,
134
+ stream_event_id text not null unique,
135
+ stream_id text not null,
136
+ source_id text not null,
137
+ source_native_id text not null,
138
+ event_variant text not null,
139
+ occurred_at text not null,
140
+ metadata_json text not null,
141
+ raw_payload_json text not null,
142
+ delivery_handle_json text,
143
+ created_at text not null,
144
+ unique(stream_id, source_native_id, event_variant)
145
+ );
146
+
147
+ create table if not exists consumers (
148
+ consumer_id text primary key,
149
+ stream_id text not null,
150
+ subscription_id text not null unique,
151
+ consumer_key text not null unique,
152
+ start_policy text not null,
153
+ start_offset integer,
154
+ start_time text,
155
+ next_offset integer not null,
156
+ created_at text not null,
157
+ updated_at text not null
158
+ );
159
+
160
+ create table if not exists consumer_commits (
161
+ commit_id text primary key,
162
+ consumer_id text not null,
163
+ stream_id text not null,
164
+ committed_offset integer not null,
165
+ committed_at text not null
166
+ );
167
+
168
+ create unique index if not exists idx_activation_targets_terminal_tmux
169
+ on activation_targets(tmux_pane_id)
170
+ where kind = 'terminal' and tmux_pane_id is not null;
171
+
172
+ create unique index if not exists idx_activation_targets_terminal_iterm
173
+ on activation_targets(iterm_session_id)
174
+ where kind = 'terminal' and iterm_session_id is not null;
175
+
176
+ create unique index if not exists idx_activation_targets_runtime_session
177
+ on activation_targets(runtime_kind, runtime_session_id)
178
+ where kind = 'terminal' and runtime_session_id is not null;
179
+
180
+ create index if not exists idx_activation_dispatch_states_target
181
+ on activation_dispatch_states(target_id, lease_expires_at);
182
+
183
+ create index if not exists idx_inbox_items_acked_at
184
+ on inbox_items(acked_at);
185
+
186
+ create index if not exists idx_inbox_items_inbox_acked_at
187
+ on inbox_items(inbox_id, acked_at);
188
+
189
+ create index if not exists idx_agents_status_offline
190
+ on agents(status, offline_since);
191
+
192
+ create index if not exists idx_activation_targets_agent_status
193
+ on activation_targets(agent_id, status);
194
+
195
+ create index if not exists idx_stream_events_stream_offset
196
+ on stream_events(stream_id, offset);
197
+
198
+ create index if not exists idx_stream_events_stream_occurred_at
199
+ on stream_events(stream_id, occurred_at);
200
+
201
+ create index if not exists idx_consumers_stream
202
+ on consumers(stream_id);
203
+
204
+ create index if not exists idx_consumer_commits_consumer
205
+ on consumer_commits(consumer_id, committed_offset);
206
+
@@ -0,0 +1,3 @@
1
+ create index if not exists idx_inbox_items_source_occurred_at
2
+ on inbox_items(source_id, occurred_at);
3
+