@omnixal/openclaw-nats-plugin 0.2.0 → 0.2.2

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.
Files changed (71) hide show
  1. package/cli/setup.ts +27 -0
  2. package/dashboard/bun.lock +253 -0
  3. package/dashboard/components.json +16 -0
  4. package/dashboard/index.html +22 -0
  5. package/dashboard/package.json +24 -0
  6. package/dashboard/src/App.svelte +107 -0
  7. package/dashboard/src/app.css +232 -0
  8. package/dashboard/src/lib/ConfigPanel.svelte +35 -0
  9. package/dashboard/src/lib/CronPanel.svelte +255 -0
  10. package/dashboard/src/lib/HealthCards.svelte +68 -0
  11. package/dashboard/src/lib/MetricsPanel.svelte +60 -0
  12. package/dashboard/src/lib/PendingTable.svelte +73 -0
  13. package/dashboard/src/lib/RoutesPanel.svelte +178 -0
  14. package/dashboard/src/lib/ThemeToggle.svelte +54 -0
  15. package/dashboard/src/lib/api.ts +141 -0
  16. package/dashboard/src/lib/components/ui/badge/badge.svelte +50 -0
  17. package/dashboard/src/lib/components/ui/badge/index.ts +2 -0
  18. package/dashboard/src/lib/components/ui/button/button.svelte +82 -0
  19. package/dashboard/src/lib/components/ui/button/index.ts +17 -0
  20. package/dashboard/src/lib/components/ui/card/card-action.svelte +20 -0
  21. package/dashboard/src/lib/components/ui/card/card-content.svelte +15 -0
  22. package/dashboard/src/lib/components/ui/card/card-description.svelte +20 -0
  23. package/dashboard/src/lib/components/ui/card/card-footer.svelte +20 -0
  24. package/dashboard/src/lib/components/ui/card/card-header.svelte +23 -0
  25. package/dashboard/src/lib/components/ui/card/card-title.svelte +20 -0
  26. package/dashboard/src/lib/components/ui/card/card.svelte +23 -0
  27. package/dashboard/src/lib/components/ui/card/index.ts +25 -0
  28. package/dashboard/src/lib/components/ui/table/index.ts +28 -0
  29. package/dashboard/src/lib/components/ui/table/table-body.svelte +20 -0
  30. package/dashboard/src/lib/components/ui/table/table-caption.svelte +20 -0
  31. package/dashboard/src/lib/components/ui/table/table-cell.svelte +23 -0
  32. package/dashboard/src/lib/components/ui/table/table-footer.svelte +20 -0
  33. package/dashboard/src/lib/components/ui/table/table-head.svelte +23 -0
  34. package/dashboard/src/lib/components/ui/table/table-header.svelte +20 -0
  35. package/dashboard/src/lib/components/ui/table/table-row.svelte +23 -0
  36. package/dashboard/src/lib/components/ui/table/table.svelte +22 -0
  37. package/dashboard/src/lib/utils.ts +29 -0
  38. package/dashboard/src/main.ts +7 -0
  39. package/dashboard/tsconfig.json +19 -0
  40. package/dashboard/vite.config.ts +30 -0
  41. package/package.json +6 -4
  42. package/plugins/nats-context-engine/http-handler.ts +1 -1
  43. package/plugins/nats-context-engine/index.ts +59 -0
  44. package/sidecar/bun.lock +8 -6
  45. package/sidecar/package.json +4 -4
  46. package/sidecar/src/app.module.ts +9 -2
  47. package/sidecar/src/consumer/consumer.controller.ts +4 -0
  48. package/sidecar/src/consumer/consumer.module.ts +2 -1
  49. package/sidecar/src/db/migrations/0004_familiar_zaladane.sql +17 -0
  50. package/sidecar/src/db/migrations/meta/0004_snapshot.json +306 -0
  51. package/sidecar/src/db/migrations/meta/_journal.json +7 -0
  52. package/sidecar/src/db/schema.ts +20 -0
  53. package/sidecar/src/health/health.service.ts +1 -1
  54. package/sidecar/src/index.ts +6 -0
  55. package/sidecar/src/metrics/metrics.controller.ts +16 -0
  56. package/sidecar/src/metrics/metrics.module.ts +10 -0
  57. package/sidecar/src/metrics/metrics.service.ts +64 -0
  58. package/sidecar/src/publisher/publisher.module.ts +2 -0
  59. package/sidecar/src/publisher/publisher.service.ts +6 -1
  60. package/sidecar/src/router/router.controller.ts +20 -12
  61. package/sidecar/src/router/router.repository.ts +39 -7
  62. package/sidecar/src/router/router.service.ts +10 -2
  63. package/sidecar/src/scheduler/scheduler.controller.ts +68 -0
  64. package/sidecar/src/scheduler/scheduler.module.ts +13 -0
  65. package/sidecar/src/scheduler/scheduler.repository.ts +64 -0
  66. package/sidecar/src/scheduler/scheduler.service.ts +138 -0
  67. package/sidecar/src/validation/schemas.ts +18 -0
  68. package/skills/nats-events/SKILL.md +41 -28
  69. package/dashboard/dist/assets/index--UFIkwvP.js +0 -2
  70. package/dashboard/dist/assets/index-CafgidIc.css +0 -2
  71. package/dashboard/dist/index.html +0 -13
@@ -0,0 +1,17 @@
1
+ CREATE TABLE `cron_jobs` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `name` text NOT NULL,
4
+ `expr` text NOT NULL,
5
+ `subject` text NOT NULL,
6
+ `payload` text,
7
+ `timezone` text DEFAULT 'UTC' NOT NULL,
8
+ `enabled` integer DEFAULT true NOT NULL,
9
+ `last_run_at` integer,
10
+ `created_at` integer NOT NULL
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE UNIQUE INDEX `cron_jobs_name_unique` ON `cron_jobs` (`name`);--> statement-breakpoint
14
+ CREATE INDEX `cron_jobs_name_idx` ON `cron_jobs` (`name`);--> statement-breakpoint
15
+ ALTER TABLE `event_routes` ADD `last_delivered_at` integer;--> statement-breakpoint
16
+ ALTER TABLE `event_routes` ADD `last_event_subject` text;--> statement-breakpoint
17
+ ALTER TABLE `event_routes` ADD `delivery_count` integer DEFAULT 0 NOT NULL;
@@ -0,0 +1,306 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "e31f4610-cdfc-459e-b947-8363975599a0",
5
+ "prevId": "cb92737c-c257-4b58-bbf0-798ab1494961",
6
+ "tables": {
7
+ "cron_jobs": {
8
+ "name": "cron_jobs",
9
+ "columns": {
10
+ "id": {
11
+ "name": "id",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "name": {
18
+ "name": "name",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "expr": {
25
+ "name": "expr",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ },
31
+ "subject": {
32
+ "name": "subject",
33
+ "type": "text",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ },
38
+ "payload": {
39
+ "name": "payload",
40
+ "type": "text",
41
+ "primaryKey": false,
42
+ "notNull": false,
43
+ "autoincrement": false
44
+ },
45
+ "timezone": {
46
+ "name": "timezone",
47
+ "type": "text",
48
+ "primaryKey": false,
49
+ "notNull": true,
50
+ "autoincrement": false,
51
+ "default": "'UTC'"
52
+ },
53
+ "enabled": {
54
+ "name": "enabled",
55
+ "type": "integer",
56
+ "primaryKey": false,
57
+ "notNull": true,
58
+ "autoincrement": false,
59
+ "default": true
60
+ },
61
+ "last_run_at": {
62
+ "name": "last_run_at",
63
+ "type": "integer",
64
+ "primaryKey": false,
65
+ "notNull": false,
66
+ "autoincrement": false
67
+ },
68
+ "created_at": {
69
+ "name": "created_at",
70
+ "type": "integer",
71
+ "primaryKey": false,
72
+ "notNull": true,
73
+ "autoincrement": false
74
+ }
75
+ },
76
+ "indexes": {
77
+ "cron_jobs_name_unique": {
78
+ "name": "cron_jobs_name_unique",
79
+ "columns": [
80
+ "name"
81
+ ],
82
+ "isUnique": true
83
+ },
84
+ "cron_jobs_name_idx": {
85
+ "name": "cron_jobs_name_idx",
86
+ "columns": [
87
+ "name"
88
+ ],
89
+ "isUnique": false
90
+ }
91
+ },
92
+ "foreignKeys": {},
93
+ "compositePrimaryKeys": {},
94
+ "uniqueConstraints": {},
95
+ "checkConstraints": {}
96
+ },
97
+ "dedup_events": {
98
+ "name": "dedup_events",
99
+ "columns": {
100
+ "event_id": {
101
+ "name": "event_id",
102
+ "type": "text",
103
+ "primaryKey": true,
104
+ "notNull": true,
105
+ "autoincrement": false
106
+ },
107
+ "subject": {
108
+ "name": "subject",
109
+ "type": "text",
110
+ "primaryKey": false,
111
+ "notNull": true,
112
+ "autoincrement": false
113
+ },
114
+ "seen_at": {
115
+ "name": "seen_at",
116
+ "type": "integer",
117
+ "primaryKey": false,
118
+ "notNull": true,
119
+ "autoincrement": false
120
+ }
121
+ },
122
+ "indexes": {
123
+ "dedup_events_seen_at_idx": {
124
+ "name": "dedup_events_seen_at_idx",
125
+ "columns": [
126
+ "seen_at"
127
+ ],
128
+ "isUnique": false
129
+ }
130
+ },
131
+ "foreignKeys": {},
132
+ "compositePrimaryKeys": {},
133
+ "uniqueConstraints": {},
134
+ "checkConstraints": {}
135
+ },
136
+ "event_routes": {
137
+ "name": "event_routes",
138
+ "columns": {
139
+ "id": {
140
+ "name": "id",
141
+ "type": "text",
142
+ "primaryKey": true,
143
+ "notNull": true,
144
+ "autoincrement": false
145
+ },
146
+ "pattern": {
147
+ "name": "pattern",
148
+ "type": "text",
149
+ "primaryKey": false,
150
+ "notNull": true,
151
+ "autoincrement": false
152
+ },
153
+ "target": {
154
+ "name": "target",
155
+ "type": "text",
156
+ "primaryKey": false,
157
+ "notNull": true,
158
+ "autoincrement": false,
159
+ "default": "'main'"
160
+ },
161
+ "enabled": {
162
+ "name": "enabled",
163
+ "type": "integer",
164
+ "primaryKey": false,
165
+ "notNull": true,
166
+ "autoincrement": false,
167
+ "default": true
168
+ },
169
+ "priority": {
170
+ "name": "priority",
171
+ "type": "integer",
172
+ "primaryKey": false,
173
+ "notNull": true,
174
+ "autoincrement": false,
175
+ "default": 5
176
+ },
177
+ "created_at": {
178
+ "name": "created_at",
179
+ "type": "integer",
180
+ "primaryKey": false,
181
+ "notNull": true,
182
+ "autoincrement": false
183
+ },
184
+ "last_delivered_at": {
185
+ "name": "last_delivered_at",
186
+ "type": "integer",
187
+ "primaryKey": false,
188
+ "notNull": false,
189
+ "autoincrement": false
190
+ },
191
+ "last_event_subject": {
192
+ "name": "last_event_subject",
193
+ "type": "text",
194
+ "primaryKey": false,
195
+ "notNull": false,
196
+ "autoincrement": false
197
+ },
198
+ "delivery_count": {
199
+ "name": "delivery_count",
200
+ "type": "integer",
201
+ "primaryKey": false,
202
+ "notNull": true,
203
+ "autoincrement": false,
204
+ "default": 0
205
+ }
206
+ },
207
+ "indexes": {
208
+ "event_routes_pattern_unique": {
209
+ "name": "event_routes_pattern_unique",
210
+ "columns": [
211
+ "pattern"
212
+ ],
213
+ "isUnique": true
214
+ },
215
+ "event_routes_pattern_idx": {
216
+ "name": "event_routes_pattern_idx",
217
+ "columns": [
218
+ "pattern"
219
+ ],
220
+ "isUnique": false
221
+ },
222
+ "event_routes_target_idx": {
223
+ "name": "event_routes_target_idx",
224
+ "columns": [
225
+ "target"
226
+ ],
227
+ "isUnique": false
228
+ }
229
+ },
230
+ "foreignKeys": {},
231
+ "compositePrimaryKeys": {},
232
+ "uniqueConstraints": {},
233
+ "checkConstraints": {}
234
+ },
235
+ "pending_events": {
236
+ "name": "pending_events",
237
+ "columns": {
238
+ "id": {
239
+ "name": "id",
240
+ "type": "text",
241
+ "primaryKey": true,
242
+ "notNull": true,
243
+ "autoincrement": false
244
+ },
245
+ "session_key": {
246
+ "name": "session_key",
247
+ "type": "text",
248
+ "primaryKey": false,
249
+ "notNull": true,
250
+ "autoincrement": false
251
+ },
252
+ "subject": {
253
+ "name": "subject",
254
+ "type": "text",
255
+ "primaryKey": false,
256
+ "notNull": true,
257
+ "autoincrement": false
258
+ },
259
+ "payload": {
260
+ "name": "payload",
261
+ "type": "text",
262
+ "primaryKey": false,
263
+ "notNull": false,
264
+ "autoincrement": false
265
+ },
266
+ "priority": {
267
+ "name": "priority",
268
+ "type": "integer",
269
+ "primaryKey": false,
270
+ "notNull": true,
271
+ "autoincrement": false,
272
+ "default": 5
273
+ },
274
+ "created_at": {
275
+ "name": "created_at",
276
+ "type": "integer",
277
+ "primaryKey": false,
278
+ "notNull": true,
279
+ "autoincrement": false
280
+ },
281
+ "delivered_at": {
282
+ "name": "delivered_at",
283
+ "type": "integer",
284
+ "primaryKey": false,
285
+ "notNull": false,
286
+ "autoincrement": false
287
+ }
288
+ },
289
+ "indexes": {},
290
+ "foreignKeys": {},
291
+ "compositePrimaryKeys": {},
292
+ "uniqueConstraints": {},
293
+ "checkConstraints": {}
294
+ }
295
+ },
296
+ "views": {},
297
+ "enums": {},
298
+ "_meta": {
299
+ "schemas": {},
300
+ "tables": {},
301
+ "columns": {}
302
+ },
303
+ "internal": {
304
+ "indexes": {}
305
+ }
306
+ }
@@ -29,6 +29,13 @@
29
29
  "when": 1773938595817,
30
30
  "tag": "0003_wet_deathbird",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1773965190659,
37
+ "tag": "0004_familiar_zaladane",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }
@@ -28,6 +28,9 @@ export const eventRoutes = sqliteTable('event_routes', {
28
28
  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
29
29
  priority: integer('priority').notNull().default(5),
30
30
  createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
31
+ lastDeliveredAt: integer('last_delivered_at', { mode: 'timestamp_ms' }),
32
+ lastEventSubject: text('last_event_subject'),
33
+ deliveryCount: integer('delivery_count').notNull().default(0),
31
34
  }, (table) => [
32
35
  index('event_routes_pattern_idx').on(table.pattern),
33
36
  index('event_routes_target_idx').on(table.target),
@@ -35,3 +38,20 @@ export const eventRoutes = sqliteTable('event_routes', {
35
38
 
36
39
  export type DbEventRoute = typeof eventRoutes.$inferSelect;
37
40
  export type NewEventRoute = typeof eventRoutes.$inferInsert;
41
+
42
+ export const cronJobs = sqliteTable('cron_jobs', {
43
+ id: text('id').primaryKey(),
44
+ name: text('name').notNull().unique(),
45
+ expr: text('expr').notNull(),
46
+ subject: text('subject').notNull(),
47
+ payload: text('payload', { mode: 'json' }).$type<unknown>(),
48
+ timezone: text('timezone').notNull().default('UTC'),
49
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
50
+ lastRunAt: integer('last_run_at', { mode: 'timestamp_ms' }),
51
+ createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
52
+ }, (table) => [
53
+ index('cron_jobs_name_idx').on(table.name),
54
+ ]);
55
+
56
+ export type DbCronJob = typeof cronJobs.$inferSelect;
57
+ export type NewCronJob = typeof cronJobs.$inferInsert;
@@ -49,7 +49,7 @@ export class HealthService extends BaseService {
49
49
  pendingCount,
50
50
  uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
51
51
  config: {
52
- streams: ['agent_events', 'agent_dlq'],
52
+ streams: ['agent_events', 'agent_dlq', 'scheduler_internal'],
53
53
  consumerName: this.config.get('consumer.name'),
54
54
  dedupTtlSeconds: this.config.get('dedup.ttlSeconds'),
55
55
  },
@@ -35,6 +35,12 @@ const app = new OneBunApplication(AppModule, {
35
35
  retention: 'limits',
36
36
  maxAge: SEVEN_DAYS_NS,
37
37
  },
38
+ {
39
+ name: 'scheduler_internal',
40
+ subjects: ['scheduler.>'],
41
+ retention: 'limits',
42
+ maxAge: 24 * 60 * 60 * 1e9, // 1 day
43
+ },
38
44
  ],
39
45
  consumerConfig: {
40
46
  ackWait: ackWaitMs * 1_000_000, // ms → ns
@@ -0,0 +1,16 @@
1
+ import { Controller, BaseController, Get, UseMiddleware } from '@onebun/core';
2
+ import { ApiKeyMiddleware } from '../auth/api-key.middleware';
3
+ import { MetricsService } from './metrics.service';
4
+
5
+ @Controller('/api/metrics')
6
+ @UseMiddleware(ApiKeyMiddleware)
7
+ export class MetricsController extends BaseController {
8
+ constructor(private metrics: MetricsService) {
9
+ super();
10
+ }
11
+
12
+ @Get('/')
13
+ getAll() {
14
+ return this.success(this.metrics.getAll());
15
+ }
16
+ }
@@ -0,0 +1,10 @@
1
+ import { Module } from '@onebun/core';
2
+ import { MetricsService } from './metrics.service';
3
+ import { MetricsController } from './metrics.controller';
4
+
5
+ @Module({
6
+ controllers: [MetricsController],
7
+ providers: [MetricsService],
8
+ exports: [MetricsService],
9
+ })
10
+ export class MetricsModule {}
@@ -0,0 +1,64 @@
1
+ import { Service, BaseService } from '@onebun/core';
2
+
3
+ export interface SubjectMetric {
4
+ subject: string;
5
+ published: number;
6
+ consumed: number;
7
+ lastPublishedAt: number | null;
8
+ lastConsumedAt: number | null;
9
+ }
10
+
11
+ interface CounterEntry {
12
+ count: number;
13
+ lastAt: number;
14
+ }
15
+
16
+ @Service()
17
+ export class MetricsService extends BaseService {
18
+ private published = new Map<string, CounterEntry>();
19
+ private consumed = new Map<string, CounterEntry>();
20
+
21
+ recordPublish(subject: string): void {
22
+ const entry = this.published.get(subject);
23
+ const now = Date.now();
24
+ if (entry) {
25
+ entry.count++;
26
+ entry.lastAt = now;
27
+ } else {
28
+ this.published.set(subject, { count: 1, lastAt: now });
29
+ }
30
+ }
31
+
32
+ recordConsume(subject: string): void {
33
+ const entry = this.consumed.get(subject);
34
+ const now = Date.now();
35
+ if (entry) {
36
+ entry.count++;
37
+ entry.lastAt = now;
38
+ } else {
39
+ this.consumed.set(subject, { count: 1, lastAt: now });
40
+ }
41
+ }
42
+
43
+ getAll(): SubjectMetric[] {
44
+ const subjects = new Set<string>([
45
+ ...this.published.keys(),
46
+ ...this.consumed.keys(),
47
+ ]);
48
+
49
+ const result: SubjectMetric[] = [];
50
+ for (const subject of subjects) {
51
+ const pub = this.published.get(subject);
52
+ const con = this.consumed.get(subject);
53
+ result.push({
54
+ subject,
55
+ published: pub?.count ?? 0,
56
+ consumed: con?.count ?? 0,
57
+ lastPublishedAt: pub?.lastAt ?? null,
58
+ lastConsumedAt: con?.lastAt ?? null,
59
+ });
60
+ }
61
+
62
+ return result.sort((a, b) => a.subject.localeCompare(b.subject));
63
+ }
64
+ }
@@ -1,8 +1,10 @@
1
1
  import { Module } from '@onebun/core';
2
2
  import { PublisherService } from './publisher.service';
3
3
  import { PublisherController } from './publisher.controller';
4
+ import { MetricsModule } from '../metrics/metrics.module';
4
5
 
5
6
  @Module({
7
+ imports: [MetricsModule],
6
8
  controllers: [PublisherController],
7
9
  providers: [PublisherService],
8
10
  exports: [PublisherService],
@@ -1,15 +1,20 @@
1
1
  import { Service, BaseService, QueueService } from '@onebun/core';
2
2
  import { createEnvelope, type EnvelopeMeta } from './envelope';
3
+ import { MetricsService } from '../metrics/metrics.service';
3
4
 
4
5
  @Service()
5
6
  export class PublisherService extends BaseService {
6
- constructor(private queueService: QueueService) {
7
+ constructor(
8
+ private queueService: QueueService,
9
+ private metrics: MetricsService,
10
+ ) {
7
11
  super();
8
12
  }
9
13
 
10
14
  async publish(subject: string, payload: unknown, meta?: EnvelopeMeta): Promise<void> {
11
15
  const envelope = createEnvelope(subject, payload, meta);
12
16
  await this.queueService.publish(subject, envelope);
17
+ this.metrics.recordPublish(subject);
13
18
  this.logger.debug(`Published to ${subject}`, { id: envelope.id });
14
19
  }
15
20
  }
@@ -10,17 +10,9 @@ import {
10
10
  UseMiddleware,
11
11
  type OneBunResponse,
12
12
  } from '@onebun/core';
13
- import { type } from 'arktype';
14
13
  import { RouterService } from './router.service';
15
14
  import { ApiKeyMiddleware } from '../auth/api-key.middleware';
16
-
17
- const createRouteBody = type({
18
- pattern: 'string',
19
- 'target?': 'string',
20
- 'priority?': 'number',
21
- });
22
-
23
- type CreateRouteBody = typeof createRouteBody.infer;
15
+ import { createRouteBodySchema, type CreateRouteBody } from '../validation/schemas';
24
16
 
25
17
  @Controller('/api/routes')
26
18
  @UseMiddleware(ApiKeyMiddleware)
@@ -35,6 +27,22 @@ export class RouterController extends BaseController {
35
27
  return this.success(status);
36
28
  }
37
29
 
30
+ @Get('/health')
31
+ async getRoutesHealth(): Promise<OneBunResponse> {
32
+ const routes = await this.routerService.listRoutes();
33
+ const now = Date.now();
34
+ const result = routes.map(r => ({
35
+ pattern: r.pattern,
36
+ target: r.target,
37
+ enabled: r.enabled,
38
+ lastDeliveredAt: r.lastDeliveredAt?.toISOString() ?? null,
39
+ lastEventSubject: r.lastEventSubject ?? null,
40
+ deliveryCount: r.deliveryCount ?? 0,
41
+ lagMs: r.lastDeliveredAt ? now - r.lastDeliveredAt.getTime() : null,
42
+ }));
43
+ return this.success(result);
44
+ }
45
+
38
46
  @Get()
39
47
  async getRoutes(@Query() query: Record<string, string>): Promise<OneBunResponse> {
40
48
  const filters: { pattern?: string; target?: string } = {};
@@ -45,16 +53,16 @@ export class RouterController extends BaseController {
45
53
  }
46
54
 
47
55
  @Post()
48
- async createRoute(@Body(createRouteBody) body: CreateRouteBody): Promise<OneBunResponse> {
56
+ async createRoute(@Body(createRouteBodySchema) body: CreateRouteBody): Promise<OneBunResponse> {
49
57
  if (!body.pattern.startsWith('agent.events.')) {
50
58
  return this.error('pattern must start with agent.events.', 400, 400);
51
59
  }
52
- const route = await this.routerService.subscribe(
60
+ const { route, created } = await this.routerService.subscribe(
53
61
  body.pattern,
54
62
  body.target ?? 'main',
55
63
  body.priority ?? 5,
56
64
  );
57
- return this.success(route);
65
+ return this.success({ ...route, created });
58
66
  }
59
67
 
60
68
  @Delete('/:id')
@@ -1,5 +1,5 @@
1
1
  import { Service, BaseService } from '@onebun/core';
2
- import { DrizzleService, eq } from '@onebun/drizzle';
2
+ import { DrizzleService, eq, sql } from '@onebun/drizzle';
3
3
  import { eventRoutes, type DbEventRoute, type NewEventRoute } from '../db/schema';
4
4
 
5
5
  @Service()
@@ -9,16 +9,18 @@ export class RouterRepository extends BaseService {
9
9
  }
10
10
 
11
11
  async findAll(filters?: { pattern?: string; target?: string }): Promise<DbEventRoute[]> {
12
- let query = this.db.select().from(eventRoutes);
12
+ // drizzle type limitation: chained .where()/.orderBy() loses type info
13
+ let query = this.db.select().from(eventRoutes) as any;
13
14
  if (filters?.pattern) {
14
- query = query.where(eq(eventRoutes.pattern, filters.pattern)) as any;
15
+ query = query.where(eq(eventRoutes.pattern, filters.pattern));
15
16
  } else if (filters?.target) {
16
- query = query.where(eq(eventRoutes.target, filters.target)) as any;
17
+ query = query.where(eq(eventRoutes.target, filters.target));
17
18
  }
18
- return query.orderBy(eventRoutes.priority) as any;
19
+ return query.orderBy(eventRoutes.priority);
19
20
  }
20
21
 
21
22
  async findEnabled(): Promise<DbEventRoute[]> {
23
+ // drizzle type limitation: chained .where()/.orderBy() loses type info
22
24
  return this.db.select().from(eventRoutes)
23
25
  .where(eq(eventRoutes.enabled, true))
24
26
  .orderBy(eventRoutes.priority) as any;
@@ -29,6 +31,34 @@ export class RouterRepository extends BaseService {
29
31
  return created;
30
32
  }
31
33
 
34
+ async upsert(route: NewEventRoute): Promise<{ route: DbEventRoute; created: boolean }> {
35
+ const [result] = await this.db
36
+ .insert(eventRoutes)
37
+ .values(route)
38
+ .onConflictDoUpdate({
39
+ target: eventRoutes.pattern,
40
+ set: {
41
+ target: sql`excluded.target`,
42
+ priority: sql`excluded.priority`,
43
+ enabled: sql`excluded.enabled`,
44
+ },
45
+ })
46
+ .returning();
47
+
48
+ const created = result.createdAt.getTime() === route.createdAt!.getTime();
49
+ return { route: result, created };
50
+ }
51
+
52
+ async recordDelivery(routeId: string, subject: string): Promise<void> {
53
+ await this.db.update(eventRoutes)
54
+ .set({
55
+ lastDeliveredAt: new Date(),
56
+ lastEventSubject: subject,
57
+ deliveryCount: sql`${eventRoutes.deliveryCount} + 1`,
58
+ })
59
+ .where(eq(eventRoutes.id, routeId));
60
+ }
61
+
32
62
  async deleteById(id: string): Promise<boolean> {
33
63
  const result = await this.db.delete(eventRoutes).where(eq(eventRoutes.id, id)).returning();
34
64
  return result.length > 0;
@@ -40,7 +70,9 @@ export class RouterRepository extends BaseService {
40
70
  }
41
71
 
42
72
  async count(): Promise<number> {
43
- const rows = await this.db.select().from(eventRoutes);
44
- return rows.length;
73
+ const result = await this.db
74
+ .select({ count: sql<number>`count(*)` })
75
+ .from(eventRoutes);
76
+ return result[0]?.count ?? 0;
45
77
  }
46
78
  }
@@ -39,8 +39,12 @@ export class RouterService extends BaseService {
39
39
  return this.repo.findAll(filters);
40
40
  }
41
41
 
42
- async subscribe(pattern: string, target: string = 'main', priority: number = 5): Promise<DbEventRoute> {
43
- return this.repo.create({
42
+ async subscribe(
43
+ pattern: string,
44
+ target: string = 'main',
45
+ priority: number = 5,
46
+ ): Promise<{ route: DbEventRoute; created: boolean }> {
47
+ return this.repo.upsert({
44
48
  id: ulid(),
45
49
  pattern,
46
50
  target,
@@ -50,6 +54,10 @@ export class RouterService extends BaseService {
50
54
  });
51
55
  }
52
56
 
57
+ async recordDelivery(routeId: string, subject: string): Promise<void> {
58
+ await this.repo.recordDelivery(routeId, subject);
59
+ }
60
+
53
61
  async unsubscribe(pattern: string): Promise<boolean> {
54
62
  return this.repo.deleteByPattern(pattern);
55
63
  }