routeflow-api 0.2.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.
Files changed (70) hide show
  1. package/README.md +93 -0
  2. package/dist/adapters/cassandra.cjs +117 -0
  3. package/dist/adapters/cassandra.cjs.map +1 -0
  4. package/dist/adapters/cassandra.d.cts +37 -0
  5. package/dist/adapters/cassandra.d.ts +37 -0
  6. package/dist/adapters/cassandra.js +90 -0
  7. package/dist/adapters/cassandra.js.map +1 -0
  8. package/dist/adapters/dynamodb.cjs +180 -0
  9. package/dist/adapters/dynamodb.cjs.map +1 -0
  10. package/dist/adapters/dynamodb.d.cts +48 -0
  11. package/dist/adapters/dynamodb.d.ts +48 -0
  12. package/dist/adapters/dynamodb.js +153 -0
  13. package/dist/adapters/dynamodb.js.map +1 -0
  14. package/dist/adapters/elasticsearch.cjs +120 -0
  15. package/dist/adapters/elasticsearch.cjs.map +1 -0
  16. package/dist/adapters/elasticsearch.d.cts +43 -0
  17. package/dist/adapters/elasticsearch.d.ts +43 -0
  18. package/dist/adapters/elasticsearch.js +93 -0
  19. package/dist/adapters/elasticsearch.js.map +1 -0
  20. package/dist/adapters/mongodb.cjs +159 -0
  21. package/dist/adapters/mongodb.cjs.map +1 -0
  22. package/dist/adapters/mongodb.d.cts +54 -0
  23. package/dist/adapters/mongodb.d.ts +54 -0
  24. package/dist/adapters/mongodb.js +132 -0
  25. package/dist/adapters/mongodb.js.map +1 -0
  26. package/dist/adapters/mysql.cjs +159 -0
  27. package/dist/adapters/mysql.cjs.map +1 -0
  28. package/dist/adapters/mysql.d.cts +63 -0
  29. package/dist/adapters/mysql.d.ts +63 -0
  30. package/dist/adapters/mysql.js +132 -0
  31. package/dist/adapters/mysql.js.map +1 -0
  32. package/dist/adapters/opensearch.cjs +120 -0
  33. package/dist/adapters/opensearch.cjs.map +1 -0
  34. package/dist/adapters/opensearch.d.cts +2 -0
  35. package/dist/adapters/opensearch.d.ts +2 -0
  36. package/dist/adapters/opensearch.js +93 -0
  37. package/dist/adapters/opensearch.js.map +1 -0
  38. package/dist/adapters/postgres.cjs +271 -0
  39. package/dist/adapters/postgres.cjs.map +1 -0
  40. package/dist/adapters/postgres.d.cts +81 -0
  41. package/dist/adapters/postgres.d.ts +81 -0
  42. package/dist/adapters/postgres.js +244 -0
  43. package/dist/adapters/postgres.js.map +1 -0
  44. package/dist/adapters/redis.cjs +153 -0
  45. package/dist/adapters/redis.cjs.map +1 -0
  46. package/dist/adapters/redis.d.cts +40 -0
  47. package/dist/adapters/redis.d.ts +40 -0
  48. package/dist/adapters/redis.js +126 -0
  49. package/dist/adapters/redis.js.map +1 -0
  50. package/dist/adapters/snowflake.cjs +117 -0
  51. package/dist/adapters/snowflake.cjs.map +1 -0
  52. package/dist/adapters/snowflake.d.cts +37 -0
  53. package/dist/adapters/snowflake.d.ts +37 -0
  54. package/dist/adapters/snowflake.js +90 -0
  55. package/dist/adapters/snowflake.js.map +1 -0
  56. package/dist/client/index.cjs +484 -0
  57. package/dist/client/index.cjs.map +1 -0
  58. package/dist/client/index.d.cts +174 -0
  59. package/dist/client/index.d.ts +174 -0
  60. package/dist/client/index.js +455 -0
  61. package/dist/client/index.js.map +1 -0
  62. package/dist/index.cjs +935 -0
  63. package/dist/index.cjs.map +1 -0
  64. package/dist/index.d.cts +190 -0
  65. package/dist/index.d.ts +190 -0
  66. package/dist/index.js +890 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/types-tPDla8AE.d.cts +75 -0
  69. package/dist/types-tPDla8AE.d.ts +75 -0
  70. package/package.json +157 -0
package/dist/index.js ADDED
@@ -0,0 +1,890 @@
1
+ // src/index.ts
2
+ import "reflect-metadata";
3
+ import Fastify from "fastify";
4
+
5
+ // src/core/decorator/route.ts
6
+ var ROUTE_METADATA = /* @__PURE__ */ Symbol("reactive-api:route");
7
+ function Route(method, path) {
8
+ return function(target, propertyKey) {
9
+ const metadata = { method, path };
10
+ Reflect.defineMetadata(ROUTE_METADATA, metadata, target, propertyKey);
11
+ };
12
+ }
13
+
14
+ // src/core/decorator/reactive.ts
15
+ var REACTIVE_METADATA = /* @__PURE__ */ Symbol("reactive-api:reactive");
16
+ function Reactive(options) {
17
+ return function(target, propertyKey) {
18
+ Reflect.defineMetadata(REACTIVE_METADATA, options, target, propertyKey);
19
+ };
20
+ }
21
+
22
+ // src/core/errors.ts
23
+ var ReactiveApiError = class extends Error {
24
+ /** Machine-readable error code (e.g. 'ADAPTER_NOT_CONNECTED', 'INVALID_ROUTE') */
25
+ code;
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.name = "ReactiveApiError";
29
+ this.code = code;
30
+ Object.setPrototypeOf(this, new.target.prototype);
31
+ }
32
+ };
33
+
34
+ // src/core/reactive/engine.ts
35
+ var ReactiveEngine = class {
36
+ constructor(adapter) {
37
+ this.adapter = adapter;
38
+ }
39
+ adapter;
40
+ endpoints = [];
41
+ /** clientId → Subscription */
42
+ subscriptions = /* @__PURE__ */ new Map();
43
+ /** table → adapter unsubscribe fn */
44
+ tableWatchers = /* @__PURE__ */ new Map();
45
+ /** "clientId:path" → debounce timer id */
46
+ debounceTimers = /* @__PURE__ */ new Map();
47
+ /**
48
+ * Register a reactive endpoint so the engine can fan-out pushes to subscribers.
49
+ */
50
+ registerEndpoint(endpoint) {
51
+ this.endpoints.push(endpoint);
52
+ const tables = Array.isArray(endpoint.options.watch) ? endpoint.options.watch : [endpoint.options.watch];
53
+ for (const table of tables) {
54
+ this.setupTableWatcher(table);
55
+ }
56
+ }
57
+ /**
58
+ * Subscribe a WebSocket client to a path.
59
+ * When the watched table(s) change and the filter passes, pushFn is called.
60
+ *
61
+ * @param clientId - Unique identifier for the client connection
62
+ * @param path - The concrete path the client subscribed to
63
+ * @param ctx - Context built from the subscribed path
64
+ * @param pushFn - Callback to deliver data to the client
65
+ */
66
+ subscribe(clientId, path, ctx, pushFn) {
67
+ this.subscriptions.set(clientId, { path, ctx, pushFn });
68
+ }
69
+ /**
70
+ * Remove a client's subscription and clean up any pending debounce timers.
71
+ */
72
+ unsubscribe(clientId) {
73
+ this.subscriptions.delete(clientId);
74
+ for (const key of this.debounceTimers.keys()) {
75
+ if (key.startsWith(`${clientId}:`)) {
76
+ clearTimeout(this.debounceTimers.get(key));
77
+ this.debounceTimers.delete(key);
78
+ }
79
+ }
80
+ }
81
+ /**
82
+ * Tear down all table watchers. Call this when the app shuts down.
83
+ */
84
+ destroy() {
85
+ for (const unsubscribe of this.tableWatchers.values()) {
86
+ unsubscribe();
87
+ }
88
+ this.tableWatchers.clear();
89
+ for (const timer of this.debounceTimers.values()) {
90
+ clearTimeout(timer);
91
+ }
92
+ this.debounceTimers.clear();
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Private helpers
96
+ // ---------------------------------------------------------------------------
97
+ setupTableWatcher(table) {
98
+ if (this.tableWatchers.has(table)) return;
99
+ const unsubscribe = this.adapter.onChange(table, (event) => {
100
+ this.onChangeEvent(event);
101
+ });
102
+ this.tableWatchers.set(table, unsubscribe);
103
+ }
104
+ onChangeEvent(event) {
105
+ const matchingEndpoints = this.endpoints.filter((ep) => {
106
+ const tables = Array.isArray(ep.options.watch) ? ep.options.watch : [ep.options.watch];
107
+ return tables.includes(event.table);
108
+ });
109
+ for (const endpoint of matchingEndpoints) {
110
+ for (const [clientId, sub] of this.subscriptions) {
111
+ if (!pathMatchesPattern(sub.path, endpoint.routePath)) continue;
112
+ if (endpoint.options.filter) {
113
+ try {
114
+ if (!endpoint.options.filter(event, sub.ctx)) continue;
115
+ } catch (err) {
116
+ continue;
117
+ }
118
+ }
119
+ this.schedulePush(clientId, endpoint, sub, event);
120
+ }
121
+ }
122
+ }
123
+ schedulePush(clientId, endpoint, sub, _event) {
124
+ const debounceMs = endpoint.options.debounce;
125
+ if (debounceMs !== void 0 && debounceMs > 0) {
126
+ const timerKey = `${clientId}:${sub.path}`;
127
+ const existing = this.debounceTimers.get(timerKey);
128
+ if (existing !== void 0) clearTimeout(existing);
129
+ const timer = setTimeout(() => {
130
+ this.debounceTimers.delete(timerKey);
131
+ this.executePush(endpoint, sub);
132
+ }, debounceMs);
133
+ this.debounceTimers.set(timerKey, timer);
134
+ } else {
135
+ this.executePush(endpoint, sub);
136
+ }
137
+ }
138
+ executePush(endpoint, sub) {
139
+ endpoint.handler(sub.ctx).then(
140
+ (data) => sub.pushFn(sub.path, data),
141
+ (err) => {
142
+ const message = err instanceof Error ? err.message : String(err);
143
+ throw new ReactiveApiError("HANDLER_ERROR", `Reactive handler failed: ${message}`);
144
+ }
145
+ );
146
+ }
147
+ };
148
+ function pathMatchesPattern(concretePath, pattern) {
149
+ const regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "([^/]+)");
150
+ const regex = new RegExp(`^${regexStr}$`);
151
+ return regex.test(concretePath);
152
+ }
153
+ function extractParams(concretePath, pattern) {
154
+ const paramNames = [];
155
+ const regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name) => {
156
+ paramNames.push(name);
157
+ return "([^/]+)";
158
+ });
159
+ const regex = new RegExp(`^${regexStr}$`);
160
+ const match = concretePath.match(regex);
161
+ if (!match) return {};
162
+ return Object.fromEntries(paramNames.map((name, i) => [name, match[i + 1]]));
163
+ }
164
+
165
+ // src/core/transport/websocket-transport.ts
166
+ import { randomUUID } from "crypto";
167
+ import { WebSocketServer, WebSocket } from "ws";
168
+ function isSubscribeMessage(value) {
169
+ return typeof value === "object" && value !== null && value["type"] === "subscribe" && typeof value["path"] === "string";
170
+ }
171
+ var WebSocketTransport = class {
172
+ constructor(engine, routePatterns) {
173
+ this.engine = engine;
174
+ this.routePatterns = routePatterns;
175
+ this.wss = new WebSocketServer({ noServer: true });
176
+ this.wss.on("connection", (ws, req) => this.handleConnection(ws, req));
177
+ }
178
+ engine;
179
+ routePatterns;
180
+ wss;
181
+ /** Maps clientId → registered route pattern, for param extraction */
182
+ clientPatterns = /* @__PURE__ */ new Map();
183
+ /**
184
+ * Attach to the raw Node.js HTTP server so WebSocket upgrade requests are
185
+ * handled alongside Fastify routes.
186
+ */
187
+ attach(httpServer) {
188
+ httpServer.on("upgrade", (req, socket, head) => {
189
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
190
+ this.wss.emit("connection", ws, req);
191
+ });
192
+ });
193
+ }
194
+ /** Gracefully close all connections. */
195
+ async close() {
196
+ return new Promise((resolve, reject) => {
197
+ this.wss.close((err) => err ? reject(err) : resolve());
198
+ });
199
+ }
200
+ // ---------------------------------------------------------------------------
201
+ // Connection handling
202
+ // ---------------------------------------------------------------------------
203
+ handleConnection(ws, _req) {
204
+ const clientId = randomUUID();
205
+ ws.on("message", (raw) => {
206
+ let parsed;
207
+ try {
208
+ parsed = JSON.parse(raw.toString());
209
+ } catch {
210
+ this.sendError(ws, "INVALID_JSON", "Message must be valid JSON");
211
+ return;
212
+ }
213
+ if (!isSubscribeMessage(parsed)) {
214
+ this.sendError(ws, "INVALID_MESSAGE", 'Expected { type: "subscribe", path: string }');
215
+ return;
216
+ }
217
+ this.handleSubscribe(ws, clientId, parsed);
218
+ });
219
+ ws.on("close", () => {
220
+ this.engine.unsubscribe(clientId);
221
+ this.clientPatterns.delete(clientId);
222
+ });
223
+ ws.on("error", (err) => {
224
+ this.engine.unsubscribe(clientId);
225
+ this.clientPatterns.delete(clientId);
226
+ if (process.env["NODE_ENV"] !== "production") {
227
+ console.error("[RouteFlow] WebSocket error:", err.message);
228
+ }
229
+ });
230
+ }
231
+ handleSubscribe(ws, clientId, msg) {
232
+ const { path, query = {} } = msg;
233
+ const pattern = this.routePatterns.find((p) => this.matchesPattern(path, p));
234
+ if (!pattern) {
235
+ this.sendError(ws, "NO_REACTIVE_ENDPOINT", `No reactive endpoint found for path: ${path}`);
236
+ return;
237
+ }
238
+ const params = extractParams(path, pattern);
239
+ const ctx = {
240
+ params,
241
+ query,
242
+ body: void 0,
243
+ headers: {}
244
+ };
245
+ const pushFn = (subscribedPath, data) => {
246
+ if (ws.readyState !== WebSocket.OPEN) return;
247
+ const msg2 = { type: "update", path: subscribedPath, data };
248
+ ws.send(JSON.stringify(msg2));
249
+ };
250
+ this.engine.unsubscribe(clientId);
251
+ this.clientPatterns.set(clientId, pattern);
252
+ this.engine.subscribe(clientId, path, ctx, pushFn);
253
+ }
254
+ matchesPattern(concretePath, pattern) {
255
+ const regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "([^/]+)");
256
+ return new RegExp(`^${regexStr}$`).test(concretePath);
257
+ }
258
+ sendError(ws, code, message) {
259
+ if (ws.readyState !== WebSocket.OPEN) return;
260
+ const msg = { type: "error", code, message };
261
+ ws.send(JSON.stringify(msg));
262
+ }
263
+ };
264
+
265
+ // src/core/transport/sse-transport.ts
266
+ import { randomUUID as randomUUID2 } from "crypto";
267
+ var SseTransport = class {
268
+ constructor(engine, routePatterns) {
269
+ this.engine = engine;
270
+ this.routePatterns = routePatterns;
271
+ }
272
+ engine;
273
+ routePatterns;
274
+ /** clientId → reply (kept open) */
275
+ connections = /* @__PURE__ */ new Map();
276
+ /**
277
+ * Register the SSE subscription endpoint on the Fastify instance.
278
+ * Must be called before `fastify.listen()`.
279
+ */
280
+ register(fastify) {
281
+ fastify.get("/_sse/subscribe", async (req, reply) => {
282
+ const query = req.query;
283
+ const path = query["path"];
284
+ if (!path) {
285
+ throw new ReactiveApiError("SSE_MISSING_PATH", 'Query param "path" is required');
286
+ }
287
+ const decodedPath = decodeURIComponent(path);
288
+ const pattern = this.routePatterns.find((p) => this.matchesPattern(decodedPath, p));
289
+ if (!pattern) {
290
+ reply.code(404);
291
+ throw new ReactiveApiError(
292
+ "SSE_NO_REACTIVE_ENDPOINT",
293
+ `No reactive endpoint found for path: ${decodedPath}`
294
+ );
295
+ }
296
+ const params = extractParams(decodedPath, pattern);
297
+ const clientQuery = { ...query };
298
+ delete clientQuery["path"];
299
+ const ctx = {
300
+ params,
301
+ query: clientQuery,
302
+ body: void 0,
303
+ headers: req.headers
304
+ };
305
+ const clientId = randomUUID2();
306
+ reply.raw.writeHead(200, {
307
+ "Content-Type": "text/event-stream",
308
+ "Cache-Control": "no-cache",
309
+ Connection: "keep-alive",
310
+ "X-Accel-Buffering": "no"
311
+ // disable nginx buffering
312
+ });
313
+ reply.raw.write(": connected\n\n");
314
+ this.connections.set(clientId, reply);
315
+ const pushFn = (subscribedPath, data) => {
316
+ if (reply.raw.destroyed) return;
317
+ const payload = JSON.stringify({ type: "update", path: subscribedPath, data });
318
+ reply.raw.write(`data: ${payload}
319
+
320
+ `);
321
+ };
322
+ this.engine.subscribe(clientId, decodedPath, ctx, pushFn);
323
+ req.raw.on("close", () => {
324
+ this.engine.unsubscribe(clientId);
325
+ this.connections.delete(clientId);
326
+ if (!reply.raw.destroyed) reply.raw.end();
327
+ });
328
+ await new Promise((resolve) => {
329
+ req.raw.on("close", resolve);
330
+ req.raw.on("error", resolve);
331
+ });
332
+ });
333
+ }
334
+ /** Close all open SSE connections. */
335
+ async close() {
336
+ for (const reply of this.connections.values()) {
337
+ if (!reply.raw.destroyed) reply.raw.end();
338
+ }
339
+ this.connections.clear();
340
+ }
341
+ matchesPattern(concretePath, pattern) {
342
+ const regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "([^/]+)");
343
+ return new RegExp(`^${regexStr}$`).test(concretePath);
344
+ }
345
+ };
346
+
347
+ // src/core/database-support.ts
348
+ var SUPPORTED_DATABASES = [
349
+ {
350
+ key: "postgresql",
351
+ name: "PostgreSQL",
352
+ aliases: ["postgres", "psql"],
353
+ categories: ["rdbms"],
354
+ supportedModes: ["native-adapter", "polling-adapter", "external-cdc-bridge"],
355
+ tier: "official"
356
+ },
357
+ {
358
+ key: "mysql",
359
+ name: "MySQL",
360
+ aliases: [],
361
+ categories: ["rdbms"],
362
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
363
+ tier: "official"
364
+ },
365
+ {
366
+ key: "mariadb",
367
+ name: "MariaDB",
368
+ aliases: [],
369
+ categories: ["rdbms"],
370
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
371
+ tier: "experimental"
372
+ },
373
+ {
374
+ key: "oracle-db",
375
+ name: "Oracle DB",
376
+ aliases: ["oracle", "oracle database"],
377
+ categories: ["rdbms"],
378
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
379
+ tier: "experimental"
380
+ },
381
+ {
382
+ key: "ms-sql-server",
383
+ name: "MS SQL Server",
384
+ aliases: ["sql server", "mssql", "ms sql"],
385
+ categories: ["rdbms"],
386
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
387
+ tier: "experimental"
388
+ },
389
+ {
390
+ key: "sqlite",
391
+ name: "SQLite",
392
+ aliases: [],
393
+ categories: ["rdbms"],
394
+ supportedModes: ["polling-adapter"],
395
+ tier: "experimental"
396
+ },
397
+ {
398
+ key: "mongodb",
399
+ name: "MongoDB",
400
+ aliases: ["mongo"],
401
+ categories: ["nosql"],
402
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
403
+ tier: "official"
404
+ },
405
+ {
406
+ key: "redis",
407
+ name: "Redis",
408
+ aliases: [],
409
+ categories: ["nosql", "in-memory"],
410
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
411
+ tier: "official"
412
+ },
413
+ {
414
+ key: "cassandra",
415
+ name: "Cassandra",
416
+ aliases: ["apache cassandra"],
417
+ categories: ["nosql"],
418
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
419
+ tier: "experimental"
420
+ },
421
+ {
422
+ key: "dynamodb",
423
+ name: "DynamoDB",
424
+ aliases: ["dynamo"],
425
+ categories: ["nosql"],
426
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
427
+ tier: "official"
428
+ },
429
+ {
430
+ key: "neo4j",
431
+ name: "Neo4j",
432
+ aliases: [],
433
+ categories: ["nosql"],
434
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
435
+ tier: "experimental"
436
+ },
437
+ {
438
+ key: "elasticsearch",
439
+ name: "Elasticsearch",
440
+ aliases: ["elastic"],
441
+ categories: ["nosql", "search-engine"],
442
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
443
+ tier: "official"
444
+ },
445
+ {
446
+ key: "hbase",
447
+ name: "HBase",
448
+ aliases: ["apache hbase"],
449
+ categories: ["nosql"],
450
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
451
+ tier: "experimental"
452
+ },
453
+ {
454
+ key: "couchdb",
455
+ name: "CouchDB",
456
+ aliases: ["apache couchdb"],
457
+ categories: ["nosql"],
458
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
459
+ tier: "experimental"
460
+ },
461
+ {
462
+ key: "influxdb",
463
+ name: "InfluxDB",
464
+ aliases: ["influx"],
465
+ categories: ["time-series"],
466
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
467
+ tier: "experimental"
468
+ },
469
+ {
470
+ key: "timescaledb",
471
+ name: "TimescaleDB",
472
+ aliases: ["timescale"],
473
+ categories: ["time-series", "rdbms"],
474
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
475
+ tier: "experimental"
476
+ },
477
+ {
478
+ key: "prometheus",
479
+ name: "Prometheus",
480
+ aliases: ["prom"],
481
+ categories: ["time-series"],
482
+ supportedModes: ["polling-adapter"],
483
+ tier: "experimental"
484
+ },
485
+ {
486
+ key: "opensearch",
487
+ name: "OpenSearch",
488
+ aliases: [],
489
+ categories: ["search-engine"],
490
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
491
+ tier: "official"
492
+ },
493
+ {
494
+ key: "solr",
495
+ name: "Solr",
496
+ aliases: ["apache solr"],
497
+ categories: ["search-engine"],
498
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
499
+ tier: "experimental"
500
+ },
501
+ {
502
+ key: "snowflake",
503
+ name: "Snowflake",
504
+ aliases: [],
505
+ categories: ["cloud-data-warehouse"],
506
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
507
+ tier: "official"
508
+ },
509
+ {
510
+ key: "bigquery",
511
+ name: "BigQuery",
512
+ aliases: ["google bigquery"],
513
+ categories: ["cloud-data-warehouse"],
514
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
515
+ tier: "experimental"
516
+ },
517
+ {
518
+ key: "redshift",
519
+ name: "Redshift",
520
+ aliases: ["amazon redshift"],
521
+ categories: ["cloud-data-warehouse"],
522
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
523
+ tier: "experimental"
524
+ },
525
+ {
526
+ key: "azure-synapse",
527
+ name: "Azure Synapse",
528
+ aliases: ["synapse", "azure synapse analytics"],
529
+ categories: ["cloud-data-warehouse"],
530
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
531
+ tier: "experimental"
532
+ },
533
+ {
534
+ key: "memcached",
535
+ name: "Memcached",
536
+ aliases: [],
537
+ categories: ["in-memory"],
538
+ supportedModes: ["polling-adapter"],
539
+ tier: "experimental"
540
+ },
541
+ {
542
+ key: "voltdb",
543
+ name: "VoltDB",
544
+ aliases: [],
545
+ categories: ["in-memory"],
546
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
547
+ tier: "experimental"
548
+ },
549
+ {
550
+ key: "cockroachdb",
551
+ name: "CockroachDB",
552
+ aliases: ["cockroach"],
553
+ categories: ["newsql", "rdbms"],
554
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
555
+ tier: "experimental"
556
+ },
557
+ {
558
+ key: "tidb",
559
+ name: "TiDB",
560
+ aliases: [],
561
+ categories: ["newsql", "rdbms"],
562
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
563
+ tier: "experimental"
564
+ },
565
+ {
566
+ key: "spanner",
567
+ name: "Spanner",
568
+ aliases: ["google spanner", "cloud spanner"],
569
+ categories: ["newsql"],
570
+ supportedModes: ["polling-adapter", "external-cdc-bridge"],
571
+ tier: "experimental"
572
+ }
573
+ ];
574
+ function normaliseDatabaseName(value) {
575
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
576
+ }
577
+ function getDatabaseSupport(name) {
578
+ const normalised = normaliseDatabaseName(name);
579
+ return SUPPORTED_DATABASES.find((database) => {
580
+ if (normaliseDatabaseName(database.name) === normalised) return true;
581
+ if (normaliseDatabaseName(database.key) === normalised) return true;
582
+ return database.aliases.some((alias) => normaliseDatabaseName(alias) === normalised);
583
+ });
584
+ }
585
+ function listSupportedDatabases(category) {
586
+ if (!category) return SUPPORTED_DATABASES;
587
+ return SUPPORTED_DATABASES.filter((database) => database.categories.includes(category));
588
+ }
589
+ function listOfficialDatabases(category) {
590
+ const databases = listSupportedDatabases(category);
591
+ return databases.filter((database) => database.tier === "official");
592
+ }
593
+
594
+ // src/core/adapter/memory-adapter.ts
595
+ var MemoryAdapter = class {
596
+ listeners = /* @__PURE__ */ new Map();
597
+ connected = false;
598
+ /** No-op — MemoryAdapter requires no real connection. */
599
+ async connect() {
600
+ this.connected = true;
601
+ }
602
+ /** No-op — clears all listeners on disconnect. */
603
+ async disconnect() {
604
+ this.listeners.clear();
605
+ this.connected = false;
606
+ }
607
+ /**
608
+ * Register a listener for changes on a specific table.
609
+ * @returns An unsubscribe function.
610
+ */
611
+ onChange(table, callback) {
612
+ if (!this.listeners.has(table)) {
613
+ this.listeners.set(table, /* @__PURE__ */ new Set());
614
+ }
615
+ this.listeners.get(table).add(callback);
616
+ return () => {
617
+ this.listeners.get(table)?.delete(callback);
618
+ };
619
+ }
620
+ /**
621
+ * Manually emit a change event on a table.
622
+ * Useful in tests and examples to simulate DB changes without a real database.
623
+ *
624
+ * @param table - Table name to emit the event on
625
+ * @param event - Change event data (table and timestamp are filled in automatically)
626
+ */
627
+ emit(table, event) {
628
+ const fullEvent = {
629
+ ...event,
630
+ table,
631
+ timestamp: Date.now()
632
+ };
633
+ const callbacks = this.listeners.get(table);
634
+ if (!callbacks) return;
635
+ for (const cb of callbacks) {
636
+ cb(fullEvent);
637
+ }
638
+ }
639
+ /** Returns true if connect() has been called and disconnect() has not. */
640
+ get isConnected() {
641
+ return this.connected;
642
+ }
643
+ };
644
+
645
+ // src/core/adapter/polling-adapter.ts
646
+ var PollingAdapter = class {
647
+ listeners = /* @__PURE__ */ new Map();
648
+ cursors = /* @__PURE__ */ new Map();
649
+ timers = /* @__PURE__ */ new Map();
650
+ activeTables = /* @__PURE__ */ new Set();
651
+ intervalMs;
652
+ now;
653
+ readChanges;
654
+ onError;
655
+ connected = false;
656
+ constructor(options) {
657
+ this.intervalMs = options.intervalMs ?? 1e3;
658
+ this.now = options.now ?? (() => Date.now());
659
+ this.readChanges = options.readChanges;
660
+ this.onError = options.onError;
661
+ }
662
+ async connect() {
663
+ this.connected = true;
664
+ for (const table of this.listeners.keys()) {
665
+ this.ensurePolling(table);
666
+ }
667
+ }
668
+ async disconnect() {
669
+ this.connected = false;
670
+ for (const timer of this.timers.values()) {
671
+ clearTimeout(timer);
672
+ }
673
+ this.timers.clear();
674
+ this.activeTables.clear();
675
+ }
676
+ onChange(table, callback) {
677
+ if (!this.listeners.has(table)) {
678
+ this.listeners.set(table, /* @__PURE__ */ new Set());
679
+ }
680
+ this.listeners.get(table).add(callback);
681
+ this.ensurePolling(table);
682
+ return () => {
683
+ const callbacks = this.listeners.get(table);
684
+ if (!callbacks) return;
685
+ callbacks.delete(callback);
686
+ if (callbacks.size === 0) {
687
+ this.listeners.delete(table);
688
+ this.stopPolling(table);
689
+ }
690
+ };
691
+ }
692
+ ensurePolling(table) {
693
+ if (!this.connected || this.activeTables.has(table)) return;
694
+ this.activeTables.add(table);
695
+ void this.poll(table);
696
+ }
697
+ stopPolling(table) {
698
+ const timer = this.timers.get(table);
699
+ if (timer) clearTimeout(timer);
700
+ this.timers.delete(table);
701
+ this.activeTables.delete(table);
702
+ this.cursors.delete(table);
703
+ }
704
+ scheduleNext(table) {
705
+ if (!this.connected || !this.listeners.has(table)) {
706
+ this.stopPolling(table);
707
+ return;
708
+ }
709
+ const timer = setTimeout(() => {
710
+ void this.poll(table);
711
+ }, this.intervalMs);
712
+ this.timers.set(table, timer);
713
+ }
714
+ async poll(table) {
715
+ if (!this.connected || !this.listeners.has(table)) {
716
+ this.stopPolling(table);
717
+ return;
718
+ }
719
+ try {
720
+ const result = await this.readChanges({
721
+ table,
722
+ cursor: this.cursors.get(table)
723
+ });
724
+ this.cursors.set(table, result.cursor);
725
+ for (const event of result.events) {
726
+ this.emit(table, event);
727
+ }
728
+ } catch (error) {
729
+ this.onError?.(error, { table });
730
+ } finally {
731
+ this.activeTables.delete(table);
732
+ this.scheduleNext(table);
733
+ }
734
+ }
735
+ emit(defaultTable, event) {
736
+ const fullEvent = {
737
+ ...event,
738
+ table: event.table ?? defaultTable,
739
+ timestamp: event.timestamp ?? this.now()
740
+ };
741
+ const callbacks = this.listeners.get(fullEvent.table);
742
+ if (!callbacks) return;
743
+ for (const callback of callbacks) {
744
+ callback(fullEvent);
745
+ }
746
+ }
747
+ };
748
+
749
+ // src/index.ts
750
+ var ReactiveApp = class {
751
+ fastify;
752
+ engine;
753
+ transport = null;
754
+ options;
755
+ /** Collected route patterns for reactive endpoints */
756
+ reactivePatterns = [];
757
+ constructor(options) {
758
+ this.options = {
759
+ transport: "websocket",
760
+ port: 3e3,
761
+ ...options
762
+ };
763
+ this.fastify = Fastify({ logger: false });
764
+ this.engine = new ReactiveEngine(this.options.adapter);
765
+ this.fastify.setErrorHandler((error, _req, reply) => {
766
+ if (error instanceof ReactiveApiError) {
767
+ const status = error.statusCode ?? 500;
768
+ reply.status(status).send({ error: error.code, message: error.message });
769
+ } else {
770
+ reply.status(500).send({ error: "INTERNAL_ERROR", message: error.message });
771
+ }
772
+ });
773
+ }
774
+ /**
775
+ * Register a controller class. Scans its methods for @Route and @Reactive
776
+ * decorators and registers HTTP routes and reactive endpoints accordingly.
777
+ *
778
+ * @param ControllerClass - A class constructor whose methods may be decorated
779
+ * with @Route and/or @Reactive.
780
+ */
781
+ register(ControllerClass) {
782
+ const instance = new ControllerClass();
783
+ const proto = Object.getPrototypeOf(instance);
784
+ const methodNames = Object.getOwnPropertyNames(proto).filter(
785
+ (name) => name !== "constructor" && typeof proto[name] === "function"
786
+ );
787
+ for (const methodName of methodNames) {
788
+ const routeMeta = Reflect.getMetadata(
789
+ ROUTE_METADATA,
790
+ proto,
791
+ methodName
792
+ );
793
+ if (!routeMeta) continue;
794
+ const reactiveMeta = Reflect.getMetadata(
795
+ REACTIVE_METADATA,
796
+ proto,
797
+ methodName
798
+ );
799
+ const handler = instance[methodName];
800
+ this.fastify.route({
801
+ method: routeMeta.method,
802
+ url: routeMeta.path,
803
+ handler: async (req, reply) => {
804
+ const ctx = {
805
+ params: req.params,
806
+ query: req.query,
807
+ body: req.body,
808
+ headers: req.headers
809
+ };
810
+ try {
811
+ const result = await handler.call(instance, ctx);
812
+ return reply.send(result);
813
+ } catch (err) {
814
+ if (err instanceof ReactiveApiError) throw err;
815
+ const msg = err instanceof Error ? err.message : String(err);
816
+ throw new ReactiveApiError("HANDLER_ERROR", msg);
817
+ }
818
+ }
819
+ });
820
+ if (reactiveMeta) {
821
+ const endpoint = {
822
+ routePath: routeMeta.path,
823
+ options: reactiveMeta,
824
+ handler: (ctx) => handler.call(instance, ctx)
825
+ };
826
+ this.engine.registerEndpoint(endpoint);
827
+ this.reactivePatterns.push(routeMeta.path);
828
+ }
829
+ }
830
+ return this;
831
+ }
832
+ /**
833
+ * Access the underlying Fastify instance for supplemental routes such as
834
+ * health checks, static assets, or demo pages.
835
+ */
836
+ getFastify() {
837
+ return this.fastify;
838
+ }
839
+ /**
840
+ * Start the HTTP server.
841
+ * Connects the database adapter before accepting connections.
842
+ *
843
+ * @param port - Override the port set in AppOptions
844
+ */
845
+ async listen(port) {
846
+ const listenPort = port ?? this.options.port;
847
+ await this.options.adapter.connect();
848
+ if (this.options.transport === "websocket") {
849
+ this.transport = new WebSocketTransport(this.engine, this.reactivePatterns);
850
+ } else if (this.options.transport === "sse") {
851
+ const sseTransport = new SseTransport(this.engine, this.reactivePatterns);
852
+ sseTransport.register(this.fastify);
853
+ this.transport = sseTransport;
854
+ }
855
+ await this.fastify.ready();
856
+ if (this.transport instanceof WebSocketTransport) {
857
+ this.transport.attach(this.fastify.server);
858
+ }
859
+ await this.fastify.listen({ port: listenPort, host: "0.0.0.0" });
860
+ console.log(
861
+ `[RouteFlow] Listening on port ${listenPort} (transport: ${this.options.transport})`
862
+ );
863
+ }
864
+ /**
865
+ * Gracefully shut down the server and disconnect from the database.
866
+ */
867
+ async close() {
868
+ this.engine.destroy();
869
+ if (this.transport) await this.transport.close();
870
+ await this.fastify.close();
871
+ await this.options.adapter.disconnect();
872
+ }
873
+ };
874
+ function createApp(options) {
875
+ return new ReactiveApp(options);
876
+ }
877
+ export {
878
+ MemoryAdapter,
879
+ PollingAdapter,
880
+ Reactive,
881
+ ReactiveApiError,
882
+ ReactiveApp,
883
+ Route,
884
+ SUPPORTED_DATABASES,
885
+ createApp,
886
+ getDatabaseSupport,
887
+ listOfficialDatabases,
888
+ listSupportedDatabases
889
+ };
890
+ //# sourceMappingURL=index.js.map