@c9up/eon 0.1.0 → 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 (91) hide show
  1. package/README.md +5 -0
  2. package/dist/EonProvider.d.ts +6 -0
  3. package/dist/EonProvider.d.ts.map +1 -1
  4. package/dist/EonProvider.js +43 -0
  5. package/dist/EonProvider.js.map +1 -1
  6. package/dist/configure.d.ts +17 -0
  7. package/dist/configure.d.ts.map +1 -0
  8. package/dist/configure.js +19 -0
  9. package/dist/configure.js.map +1 -0
  10. package/dist/connection/config.d.ts +8 -0
  11. package/dist/connection/config.d.ts.map +1 -1
  12. package/dist/connection/config.js.map +1 -1
  13. package/dist/connection/websocket.d.ts +13 -0
  14. package/dist/connection/websocket.d.ts.map +1 -1
  15. package/dist/connection/websocket.js +16 -1
  16. package/dist/connection/websocket.js.map +1 -1
  17. package/dist/health.d.ts +50 -0
  18. package/dist/health.d.ts.map +1 -0
  19. package/dist/health.js +73 -0
  20. package/dist/health.js.map +1 -0
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/query/TimeSeriesQuery.d.ts +12 -1
  26. package/dist/query/TimeSeriesQuery.d.ts.map +1 -1
  27. package/dist/query/TimeSeriesQuery.js +12 -1
  28. package/dist/query/TimeSeriesQuery.js.map +1 -1
  29. package/dist/schema/EonMigrationRunner.d.ts +35 -3
  30. package/dist/schema/EonMigrationRunner.d.ts.map +1 -1
  31. package/dist/schema/EonMigrationRunner.js +169 -7
  32. package/dist/schema/EonMigrationRunner.js.map +1 -1
  33. package/dist/schema/index.d.ts +15 -0
  34. package/dist/schema/index.d.ts.map +1 -0
  35. package/dist/schema/index.js +15 -0
  36. package/dist/schema/index.js.map +1 -0
  37. package/dist/testing/FakeEonConnection.d.ts +1 -1
  38. package/dist/testing/FakeEonConnection.d.ts.map +1 -1
  39. package/dist/testing/FakeEonConnection.js +27 -3
  40. package/dist/testing/FakeEonConnection.js.map +1 -1
  41. package/dist/testing/FakeEonConsumer.d.ts +28 -0
  42. package/dist/testing/FakeEonConsumer.d.ts.map +1 -0
  43. package/dist/testing/FakeEonConsumer.js +66 -0
  44. package/dist/testing/FakeEonConsumer.js.map +1 -0
  45. package/dist/testing/cleanup.d.ts +50 -0
  46. package/dist/testing/cleanup.d.ts.map +1 -0
  47. package/dist/testing/cleanup.js +88 -0
  48. package/dist/testing/cleanup.js.map +1 -0
  49. package/dist/testing/index.d.ts +6 -6
  50. package/dist/testing/index.d.ts.map +1 -1
  51. package/dist/testing/index.js +6 -14
  52. package/dist/testing/index.js.map +1 -1
  53. package/dist/testing/vitest.d.ts +7 -0
  54. package/dist/testing/vitest.d.ts.map +1 -0
  55. package/dist/testing/vitest.js +28 -0
  56. package/dist/testing/vitest.js.map +1 -0
  57. package/dist/tmq/EonConsumer.d.ts +99 -0
  58. package/dist/tmq/EonConsumer.d.ts.map +1 -0
  59. package/dist/tmq/EonConsumer.js +27 -0
  60. package/dist/tmq/EonConsumer.js.map +1 -0
  61. package/dist/tmq/index.d.ts +4 -0
  62. package/dist/tmq/index.d.ts.map +1 -0
  63. package/dist/tmq/index.js +4 -0
  64. package/dist/tmq/index.js.map +1 -0
  65. package/dist/tmq/websocket.d.ts +11 -0
  66. package/dist/tmq/websocket.d.ts.map +1 -0
  67. package/dist/tmq/websocket.js +189 -0
  68. package/dist/tmq/websocket.js.map +1 -0
  69. package/index.darwin-arm64.node +0 -0
  70. package/index.darwin-x64.node +0 -0
  71. package/index.linux-arm64-gnu.node +0 -0
  72. package/index.linux-x64-gnu.node +0 -0
  73. package/index.win32-x64-msvc.node +0 -0
  74. package/package.json +12 -3
  75. package/src/EonProvider.ts +61 -0
  76. package/src/configure.ts +38 -0
  77. package/src/connection/config.ts +8 -0
  78. package/src/connection/websocket.ts +17 -0
  79. package/src/health.ts +99 -0
  80. package/src/index.ts +17 -1
  81. package/src/query/TimeSeriesQuery.ts +12 -1
  82. package/src/schema/EonMigrationRunner.ts +205 -9
  83. package/src/schema/index.ts +28 -0
  84. package/src/testing/FakeEonConnection.ts +37 -7
  85. package/src/testing/FakeEonConsumer.ts +93 -0
  86. package/src/testing/cleanup.ts +120 -0
  87. package/src/testing/index.ts +11 -14
  88. package/src/testing/vitest.ts +27 -0
  89. package/src/tmq/EonConsumer.ts +111 -0
  90. package/src/tmq/index.ts +10 -0
  91. package/src/tmq/websocket.ts +217 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Data subscription — TDengine's TMQ, as a transport-neutral seam.
3
+ *
4
+ * eon could write and read; it could not be TOLD when something arrived. TMQ is
5
+ * how TDengine streams changes to a consumer group, and without it an app that
6
+ * wants a live dashboard has to poll a `SELECT` on a timer.
7
+ *
8
+ * Mirrors {@link EonConnection}'s posture: this file declares the contract and
9
+ * leaks no ws type, so a native transport can implement the same seam later
10
+ * without any consumer changing.
11
+ */
12
+
13
+ /** Where a consumer group starts when it has no committed offset. */
14
+ export type EonOffsetReset = "earliest" | "latest" | "none";
15
+
16
+ export interface EonConsumerConfig {
17
+ /** taosAdapter WebSocket endpoint, e.g. `ws://localhost:6041`. */
18
+ url: string;
19
+ /**
20
+ * The consumer group.
21
+ *
22
+ * Two consumers sharing a group split the partitions between them; two with
23
+ * DIFFERENT groups each receive everything. Getting this wrong is the
24
+ * classic way to either duplicate every message or silently process half.
25
+ */
26
+ groupId: string;
27
+ /** Identifies this consumer within its group, in server-side diagnostics. */
28
+ clientId?: string;
29
+ user?: string;
30
+ password?: string;
31
+ token?: string;
32
+ database?: string;
33
+ /** Where to start with no committed offset. Default: `latest`. */
34
+ offsetReset?: EonOffsetReset;
35
+ /**
36
+ * Commit offsets on a timer.
37
+ *
38
+ * Default FALSE, which deviates from the driver: auto-commit acknowledges
39
+ * messages the app may not have finished with, so a crash loses them with no
40
+ * trace. Opt in once your handler is idempotent.
41
+ */
42
+ autoCommit?: boolean;
43
+ /** Auto-commit period in ms. Ignored unless `autoCommit` is true. */
44
+ autoCommitIntervalMs?: number;
45
+ /** Include the source table name with each message. Default: true. */
46
+ withTableName?: boolean;
47
+ }
48
+
49
+ /** One topic's worth of a poll. */
50
+ export interface EonMessage<T = Record<string, unknown>> {
51
+ topic: string;
52
+ rows: T[];
53
+ }
54
+
55
+ /** A position in one topic's virtual group. */
56
+ export interface EonTopicPartition {
57
+ topic: string;
58
+ vgroupId: number;
59
+ offset?: bigint;
60
+ begin?: bigint;
61
+ end?: bigint;
62
+ }
63
+
64
+ /**
65
+ * A subscribed consumer.
66
+ *
67
+ * The lifecycle is `subscribe` → `poll` in a loop → `commit` → `close`.
68
+ */
69
+ export interface EonConsumer {
70
+ /** Start receiving from these topics. Replaces any previous subscription. */
71
+ subscribe(topics: string[]): Promise<void>;
72
+ /** The topics currently subscribed to, as the server sees them. */
73
+ subscription(): Promise<string[]>;
74
+ /**
75
+ * Wait up to `timeoutMs` for messages. Resolves to an empty array on timeout
76
+ * — that is a normal quiet period, not an error.
77
+ */
78
+ poll<T = Record<string, unknown>>(
79
+ timeoutMs: number,
80
+ ): Promise<EonMessage<T>[]>;
81
+ /** Acknowledge everything polled so far. */
82
+ commit(): Promise<EonTopicPartition[]>;
83
+ /** The partitions assigned to this consumer. */
84
+ assignment(topics?: string[]): Promise<EonTopicPartition[]>;
85
+ /** Move one partition to an offset. */
86
+ seek(partition: EonTopicPartition): Promise<void>;
87
+ /** Stop receiving, without closing the connection. */
88
+ unsubscribe(): Promise<void>;
89
+ /** Release the consumer. Safe to call twice. */
90
+ close(): Promise<void>;
91
+ }
92
+
93
+ /**
94
+ * Raised by every consumer operation.
95
+ *
96
+ * Follows {@link EonConnectionError}'s shape — the connector's numeric code is
97
+ * carried verbatim rather than swallowed, so a caller can branch on it.
98
+ */
99
+ export class EonConsumerError extends Error {
100
+ /** The underlying TDengine numeric error code, when the failure came from the connector. */
101
+ readonly code?: number;
102
+
103
+ constructor(message: string, options?: { cause?: unknown; code?: number }) {
104
+ super(
105
+ message,
106
+ options?.cause !== undefined ? { cause: options.cause } : undefined,
107
+ );
108
+ this.name = "EonConsumerError";
109
+ this.code = options?.code;
110
+ }
111
+ }
@@ -0,0 +1,10 @@
1
+ /** `@c9up/eon/tmq` — data subscription, without the rest of the barrel. */
2
+ export {
3
+ type EonConsumer,
4
+ type EonConsumerConfig,
5
+ EonConsumerError,
6
+ type EonMessage,
7
+ type EonOffsetReset,
8
+ type EonTopicPartition,
9
+ } from "./EonConsumer.js";
10
+ export { connectWsConsumer } from "./websocket.js";
@@ -0,0 +1,217 @@
1
+ /**
2
+ * WebSocket implementation of {@link EonConsumer}, over `@tdengine/websocket`'s
3
+ * `WsConsumer` (ws-only via taosAdapter, like the SQL connection).
4
+ *
5
+ * Thin by design: the driver already speaks TMQ. What this adds is the seam
6
+ * (no ws type leaks out), decoded rows instead of a `TaosResult`, one error
7
+ * type, and two defaults chosen for safety rather than convenience — see
8
+ * `autoCommit` and `offsetReset`.
9
+ */
10
+ import {
11
+ type TaosResult,
12
+ TDWebSocketClientError,
13
+ TMQConstants,
14
+ WsConsumer,
15
+ } from "@tdengine/websocket";
16
+ import {
17
+ type EonConsumer,
18
+ type EonConsumerConfig,
19
+ EonConsumerError,
20
+ type EonMessage,
21
+ type EonTopicPartition,
22
+ } from "./EonConsumer.js";
23
+
24
+ function wrapError(cause: unknown, message: string): EonConsumerError {
25
+ if (cause instanceof TDWebSocketClientError) {
26
+ return new EonConsumerError(`${message}: ${cause.message}`, {
27
+ cause,
28
+ code: cause.code,
29
+ });
30
+ }
31
+ return new EonConsumerError(message, { cause });
32
+ }
33
+
34
+ /** Zip a poll result's column metadata with its rows. */
35
+ function decode<T>(result: TaosResult): T[] {
36
+ const meta = result.getMeta() ?? [];
37
+ const data = result.getData() ?? [];
38
+ if (data.length > 0 && meta.length === 0) {
39
+ // The same trap the query decoder guards: rows with no column metadata
40
+ // would each decode to `{}` — silent data loss. Fail loud.
41
+ throw new EonConsumerError(
42
+ `[E_EON_DECODE] poll returned rows but no column metadata for topic '${result.getTopic()}'; cannot decode`,
43
+ );
44
+ }
45
+ return data.map((row: unknown[]) => {
46
+ const record: Record<string, unknown> = {};
47
+ meta.forEach((column, index) => {
48
+ record[column.name] = row[index];
49
+ });
50
+ return record as T;
51
+ });
52
+ }
53
+
54
+ function toPartition(p: {
55
+ topic: string;
56
+ vgroup_id: number;
57
+ offset?: bigint;
58
+ begin?: bigint;
59
+ end?: bigint;
60
+ }): EonTopicPartition {
61
+ return {
62
+ topic: p.topic,
63
+ vgroupId: p.vgroup_id,
64
+ offset: p.offset,
65
+ begin: p.begin,
66
+ end: p.end,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Open a consumer against a taosAdapter WebSocket endpoint.
72
+ *
73
+ * `autoCommit` defaults to FALSE, where the driver defaults it on. Auto-commit
74
+ * acknowledges messages the handler may not have finished with, so a crash
75
+ * drops them silently; a subscription that loses data without saying so is
76
+ * worse than one that redelivers.
77
+ */
78
+ export async function connectWsConsumer(
79
+ config: EonConsumerConfig,
80
+ ): Promise<EonConsumer> {
81
+ const options = new Map<string, string>([
82
+ [TMQConstants.WS_URL, config.url],
83
+ [TMQConstants.GROUP_ID, config.groupId],
84
+ [TMQConstants.AUTO_OFFSET_RESET, config.offsetReset ?? "latest"],
85
+ [
86
+ TMQConstants.ENABLE_AUTO_COMMIT,
87
+ config.autoCommit === true ? "true" : "false",
88
+ ],
89
+ [
90
+ TMQConstants.MSG_WITH_TABLE_NAME,
91
+ config.withTableName === false ? "false" : "true",
92
+ ],
93
+ ]);
94
+ if (config.clientId) options.set(TMQConstants.CLIENT_ID, config.clientId);
95
+ if (config.user) options.set(TMQConstants.CONNECT_USER, config.user);
96
+ if (config.password) options.set(TMQConstants.CONNECT_PASS, config.password);
97
+ if (config.token) options.set(TMQConstants.CONNECT_TOKEN, config.token);
98
+ if (config.autoCommit === true && config.autoCommitIntervalMs !== undefined) {
99
+ options.set(
100
+ TMQConstants.AUTO_COMMIT_INTERVAL_MS,
101
+ String(config.autoCommitIntervalMs),
102
+ );
103
+ }
104
+
105
+ let consumer: WsConsumer;
106
+ try {
107
+ consumer = await WsConsumer.newConsumer(options);
108
+ } catch (error) {
109
+ throw wrapError(error, `eon: failed to open a consumer on '${config.url}'`);
110
+ }
111
+
112
+ let closed = false;
113
+ function ensureOpen(): void {
114
+ if (closed) {
115
+ throw new EonConsumerError("eon: consumer is closed");
116
+ }
117
+ }
118
+
119
+ return {
120
+ async subscribe(topics: string[]): Promise<void> {
121
+ ensureOpen();
122
+ if (topics.length === 0) {
123
+ // An empty subscribe is accepted by the server and then delivers
124
+ // nothing forever — a silent no-op is the worst possible answer.
125
+ throw new EonConsumerError("eon: subscribe() needs at least one topic");
126
+ }
127
+ try {
128
+ await consumer.subscribe(topics);
129
+ } catch (error) {
130
+ throw wrapError(
131
+ error,
132
+ `eon: subscribe failed for [${topics.join(", ")}]`,
133
+ );
134
+ }
135
+ },
136
+
137
+ async subscription(): Promise<string[]> {
138
+ ensureOpen();
139
+ try {
140
+ return await consumer.subscription();
141
+ } catch (error) {
142
+ throw wrapError(error, "eon: reading the subscription failed");
143
+ }
144
+ },
145
+
146
+ async poll<T = Record<string, unknown>>(
147
+ timeoutMs: number,
148
+ ): Promise<EonMessage<T>[]> {
149
+ ensureOpen();
150
+ let polled: Map<string, TaosResult>;
151
+ try {
152
+ polled = await consumer.poll(timeoutMs);
153
+ } catch (error) {
154
+ throw wrapError(error, "eon: poll failed");
155
+ }
156
+ const out: EonMessage<T>[] = [];
157
+ for (const [topic, result] of polled) {
158
+ out.push({ topic, rows: decode<T>(result) });
159
+ }
160
+ return out;
161
+ },
162
+
163
+ async commit(): Promise<EonTopicPartition[]> {
164
+ ensureOpen();
165
+ try {
166
+ return (await consumer.commit()).map(toPartition);
167
+ } catch (error) {
168
+ throw wrapError(error, "eon: commit failed");
169
+ }
170
+ },
171
+
172
+ async assignment(topics?: string[]): Promise<EonTopicPartition[]> {
173
+ ensureOpen();
174
+ try {
175
+ return (await consumer.assignment(topics)).map(toPartition);
176
+ } catch (error) {
177
+ throw wrapError(error, "eon: reading the assignment failed");
178
+ }
179
+ },
180
+
181
+ async seek(partition: EonTopicPartition): Promise<void> {
182
+ ensureOpen();
183
+ if (partition.offset === undefined) {
184
+ throw new EonConsumerError("eon: seek() needs an offset");
185
+ }
186
+ try {
187
+ await consumer.seek({
188
+ topic: partition.topic,
189
+ vgroup_id: partition.vgroupId,
190
+ offset: partition.offset,
191
+ } as Parameters<WsConsumer["seek"]>[0]);
192
+ } catch (error) {
193
+ throw wrapError(error, `eon: seek failed on '${partition.topic}'`);
194
+ }
195
+ },
196
+
197
+ async unsubscribe(): Promise<void> {
198
+ ensureOpen();
199
+ try {
200
+ await consumer.unsubscribe();
201
+ } catch (error) {
202
+ throw wrapError(error, "eon: unsubscribe failed");
203
+ }
204
+ },
205
+
206
+ async close(): Promise<void> {
207
+ // Idempotent: a `finally` that closes twice must not throw.
208
+ if (closed) return;
209
+ closed = true;
210
+ try {
211
+ await consumer.close();
212
+ } catch (error) {
213
+ throw wrapError(error, "eon: closing the consumer failed");
214
+ }
215
+ },
216
+ };
217
+ }