@dataworks-technology/data 0.1.8 → 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.
package/README.md CHANGED
@@ -28,6 +28,7 @@ You need a Dataworks developer account. Contact your Dataworks administrator to
28
28
  | `errorUrl` | API Gateway endpoint for error reporting |
29
29
  | `realtimeUrl` | AppSync Events API endpoint for subscriptions |
30
30
  | Username + password | Your developer login credentials |
31
+ | `apiKey` (optional) | AppSync Events API key for subscription-only access |
31
32
 
32
33
  ## Installation
33
34
 
@@ -58,6 +59,7 @@ const dataworks = new DataClient({
58
59
  ingestUrl: "https://your-ingest-endpoint.dataworks.live",
59
60
  errorUrl: "https://your-error-endpoint.dataworks.live",
60
61
  realtimeUrl: "https://your-realtime-endpoint.dataworks.live",
62
+ apiKey: "your-appsync-events-api-key", // optional
61
63
  });
62
64
 
63
65
  // Authenticate
@@ -94,9 +96,33 @@ const dataworks = new DataClient({
94
96
  });
95
97
  ```
96
98
 
99
+ #### API Key Authentication (Subscription-Only)
100
+
101
+ For read-only access to real-time subscriptions without Cognito credentials:
102
+
103
+ ```typescript
104
+ import { DataClient, SubscriptionEvent } from "@dataworks-technology/data";
105
+
106
+ const dataworks = new DataClient({
107
+ cognitoEndpoint: "https://cognito-idp.eu-west-1.amazonaws.com/",
108
+ clientId: "unused",
109
+ ingestUrl: "https://unused.dataworks.live",
110
+ errorUrl: "https://unused.dataworks.live",
111
+ realtimeUrl: "https://realtime.dataworks.live",
112
+ apiKey: "da2-xxxxxxxxxx", // AppSync Events API key
113
+ });
114
+
115
+ // No login required — subscribe directly
116
+ const sub = dataworks.subscribe("dataworks/*", (event: SubscriptionEvent) => {
117
+ console.log(event.athleteId, event.metrics);
118
+ });
119
+ ```
120
+
121
+ > **Note:** API key authentication only supports subscriptions. Ingest and error reporting require Cognito credentials. If both `apiKey` and Cognito credentials are provided, Cognito takes precedence.
122
+
97
123
  ### `dataworks.login(username, password)`
98
124
 
99
- Authenticate with Cognito. Must be called before any other operation.
125
+ Authenticate with Cognito. Must be called before any other operation (unless using API key for subscriptions).
100
126
 
101
127
  ```typescript
102
128
  const result = await dataworks.login("username", "password");
@@ -105,7 +131,7 @@ const result = await dataworks.login("username", "password");
105
131
 
106
132
  ### `dataworks.ingest(metrics, eventId, datasetDatasourceId)`
107
133
 
108
- Send metric data points to the Data Engine. Invalid metrics are automatically filtered out.
134
+ Send metric data points to the Data Engine. Invalid metrics are dropped with warnings.
109
135
 
110
136
  ```typescript
111
137
  await dataworks.ingest(
@@ -118,6 +144,21 @@ await dataworks.ingest(
118
144
  );
119
145
  ```
120
146
 
147
+ ### `dataworks.ingestWithResult(metrics, eventId, datasetDatasourceId, options?)`
148
+
149
+ Ingest with structured validation diagnostics.
150
+
151
+ - Default mode (`best-effort`): invalid metrics are dropped and valid metrics continue.
152
+ - Strict mode (`strict`): throws `MetricValidationError` if any metric is invalid.
153
+
154
+ ```typescript
155
+ const result = await dataworks.ingestWithResult(metrics, "event-123", "ds-456", {
156
+ validationMode: "best-effort",
157
+ });
158
+
159
+ console.log(result.acceptedCount, result.droppedCount, result.requestSent);
160
+ ```
161
+
121
162
  ### `dataworks.subscribe(channel, onEvent, onError?)`
122
163
 
123
164
  Subscribe to real-time data events via WebSocket.
@@ -125,13 +166,16 @@ Subscribe to real-time data events via WebSocket.
125
166
  Tip: start with `dataworks/1/1/*` to inspect all metrics for a dataset-datasource/event pair, then narrow to a specific metric channel such as `dataworks/1/1/heartrate`.
126
167
 
127
168
  ```typescript
169
+ import { SubscriptionEvent, SubscriptionError } from "@dataworks-technology/data";
170
+
128
171
  const subscription = dataworks.subscribe(
129
172
  "dataworks/1/1/heartrate",
130
- (event) => {
131
- console.log("Received:", event);
173
+ (event: SubscriptionEvent) => {
174
+ console.log("Athlete:", event.athleteId);
175
+ console.log("Metrics:", event.metrics);
132
176
  },
133
- (error) => {
134
- console.error("Subscription error:", error.message);
177
+ (error: SubscriptionError) => {
178
+ console.error("Error:", error.message, "Code:", error.code);
135
179
  },
136
180
  );
137
181
 
@@ -139,10 +183,61 @@ const subscription = dataworks.subscribe(
139
183
  subscription.close();
140
184
  ```
141
185
 
142
- The optional `onError` callback receives an `Error` for:
143
- - **WebSocket connection errors** (network failures, TLS errors)
144
- - **AppSync subscription errors** (invalid channel, server-side errors)
145
- - **Reconnect failures** (token refresh failed after an auth expiry)
186
+ #### Event Type: `SubscriptionEvent`
187
+
188
+ The standard Data Engine event shape:
189
+
190
+ ```typescript
191
+ interface SubscriptionEvent {
192
+ name: string; // e.g. "heartrateingested"
193
+ timestamp: number; // Unix timestamp (milliseconds)
194
+ timestampSec: number; // Unix timestamp (seconds)
195
+ eventId: string;
196
+ datasetDatasourceId: string;
197
+ athleteId: string;
198
+ metrics: SubscriptionMetric[];
199
+ }
200
+
201
+ interface SubscriptionMetric {
202
+ metric: string; // e.g. "current", "heartrate"
203
+ value: number | string;
204
+ }
205
+ ```
206
+
207
+ #### Custom Event Types
208
+
209
+ If your channel publishes a different format, use the generic type parameter:
210
+
211
+ ```typescript
212
+ interface MyCustomEvent {
213
+ athleteId: string;
214
+ customField: number;
215
+ }
216
+
217
+ dataworks.subscribe<MyCustomEvent>("custom/*", (event) => {
218
+ console.log(event.customField); // TypeScript knows this exists
219
+ });
220
+ ```
221
+
222
+ #### Error Type: `SubscriptionError`
223
+
224
+ ```typescript
225
+ interface SubscriptionError {
226
+ message: string;
227
+ code: "CONNECTION_ERROR" | "UNAUTHORIZED" | "SUBSCRIPTION_ERROR"
228
+ | "PARSE_ERROR" | "RECONNECT_FAILED" | "UNKNOWN";
229
+ cause?: Error;
230
+ }
231
+ ```
232
+
233
+ | Code | Description |
234
+ |------|-------------|
235
+ | `CONNECTION_ERROR` | WebSocket connection failed (network, TLS) |
236
+ | `UNAUTHORIZED` | Auth token expired or invalid |
237
+ | `SUBSCRIPTION_ERROR` | AppSync subscription error (invalid channel, server error) |
238
+ | `PARSE_ERROR` | Malformed message or event payload |
239
+ | `RECONNECT_FAILED` | Token refresh failed after auth expiry |
240
+ | `UNKNOWN` | Unexpected error |
146
241
 
147
242
  ### `dataworks.reportError(error)`
148
243
 
@@ -234,6 +329,10 @@ import type {
234
329
  SubscriptionHandler,
235
330
  ErrorHandler,
236
331
  Subscription,
332
+ ValidationMode,
333
+ DroppedMetric,
334
+ IngestOptions,
335
+ IngestResult,
237
336
  } from "@dataworks-technology/data";
238
337
  ```
239
338
 
@@ -283,6 +382,10 @@ try {
283
382
 
284
383
  Calling `ingest()`, `reportError()`, or `subscribe()` before `login()` throws immediately.
285
384
 
385
+ Exception: `subscribe()` can run without `login()` when `apiKey` is configured. This mode is subscription-only — `ingest()` and `reportError()` still require `login()`.
386
+
387
+ When both Cognito credentials (`login(username, password)` + `clientId`) and `apiKey` are available, the SDK always uses Cognito auth for subscriptions.
388
+
286
389
  ## Requirements
287
390
 
288
391
  - **Node.js** ≥ 18 (uses native `fetch`)
package/dist/index.cjs CHANGED
@@ -31,11 +31,32 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  DataClient: () => DataClient,
34
+ MetricValidationError: () => MetricValidationError,
34
35
  filterValidMetrics: () => filterValidMetrics2,
35
36
  validateMetric: () => validateMetric2
36
37
  });
37
38
  module.exports = __toCommonJS(index_exports);
38
39
 
40
+ // node_modules/@dataworks/sdk/dist/client/events-auth.js
41
+ var INGEST_AUTH_ERROR = "Authentication failed: username, password, and clientId are required for ingest.";
42
+ function resolveEventsAuth(input) {
43
+ const userToken = input.userToken?.trim();
44
+ if (userToken) {
45
+ return { mode: "userCredentials", token: userToken };
46
+ }
47
+ const apiKey = input.apiKey?.trim();
48
+ if (apiKey) {
49
+ return { mode: "apiKey", apiKey };
50
+ }
51
+ throw new Error(INGEST_AUTH_ERROR);
52
+ }
53
+ function buildEventsAuthHeader(resolved, host) {
54
+ if (resolved.mode === "userCredentials") {
55
+ return { Authorization: resolved.token, host };
56
+ }
57
+ return { "x-api-key": resolved.apiKey, host };
58
+ }
59
+
39
60
  // node_modules/@dataworks/sdk/dist/client/subscription-manager.js
40
61
  function createAutoRefreshingSubscription(options) {
41
62
  const maxReconnectAttempts = options.maxReconnectAttempts ?? 1;
@@ -238,8 +259,22 @@ async function computeSecretHashAsync(username, clientId, clientSecret) {
238
259
  // src/validation.ts
239
260
  var validateMetric2 = validateMetric;
240
261
  var filterValidMetrics2 = filterValidMetrics;
262
+ var getMetricValidationResult2 = getMetricValidationResult;
263
+
264
+ // src/types.ts
265
+ var MetricValidationError = class extends Error {
266
+ constructor(message, dropped) {
267
+ super(message);
268
+ this.code = "METRIC_VALIDATION_FAILED";
269
+ this.name = "MetricValidationError";
270
+ this.dropped = dropped;
271
+ }
272
+ };
241
273
 
242
274
  // src/data-client.ts
275
+ function createSubscriptionError(message, code, cause) {
276
+ return { message, code, cause };
277
+ }
243
278
  var DataClient = class {
244
279
  constructor(config) {
245
280
  this.credentials = null;
@@ -267,7 +302,7 @@ var DataClient = class {
267
302
  }
268
303
  /**
269
304
  * Ingest metric data points into the Dataworks Data Engine.
270
- * Validates each metric before sending — invalid metrics are silently dropped.
305
+ * Validates each metric before sending — invalid metrics are dropped with warnings.
271
306
  *
272
307
  * @param metrics - Array of metric data points
273
308
  * @param eventId - Event identifier
@@ -276,12 +311,53 @@ var DataClient = class {
276
311
  * @see https://data-sdk-docs.dataworks.live/ingesting-data
277
312
  */
278
313
  async ingest(metrics, eventId, datasetDatasourceId) {
279
- this.requireAuth();
280
- const valid = filterValidMetrics2(
281
- metrics,
282
- (msg) => console.warn(`[@dataworks-technology/data] ${msg}`)
283
- );
284
- if (valid.length === 0) return;
314
+ await this.ingestWithResult(metrics, eventId, datasetDatasourceId);
315
+ }
316
+ /**
317
+ * Ingest metrics with structured validation diagnostics.
318
+ *
319
+ * By default (`validationMode: "best-effort"`), invalid metrics are dropped and
320
+ * valid metrics continue. In strict mode, invalid metrics cause a
321
+ * MetricValidationError before any network request is sent.
322
+ *
323
+ * @returns IngestResult with accepted/dropped counts and dropped reasons.
324
+ */
325
+ async ingestWithResult(metrics, eventId, datasetDatasourceId, options = {}) {
326
+ this.requireIngestAuth();
327
+ const validationMode = options.validationMode ?? "best-effort";
328
+ const validation = getMetricValidationResult2(metrics);
329
+ const valid = validation.valid;
330
+ const dropped = validation.dropped.map((item) => ({
331
+ index: item.index,
332
+ reason: item.reason,
333
+ metric: item.metric
334
+ }));
335
+ dropped.forEach((item) => {
336
+ options.onDroppedMetric?.(item);
337
+ console.warn(
338
+ `[@dataworks-technology/data] Dropping invalid metric [${String(item.metric.metric)}=${String(item.metric.value)}]: ${item.reason}`
339
+ );
340
+ });
341
+ if (validationMode === "strict" && dropped.length > 0) {
342
+ throw new MetricValidationError(
343
+ "[@dataworks-technology/data] validation failed: one or more metrics are invalid",
344
+ dropped
345
+ );
346
+ }
347
+ if (valid.length === 0) {
348
+ if (options.requireAtLeastOneValidMetric) {
349
+ throw new MetricValidationError(
350
+ "[@dataworks-technology/data] validation failed: no valid metrics to ingest",
351
+ dropped
352
+ );
353
+ }
354
+ return {
355
+ acceptedCount: 0,
356
+ droppedCount: dropped.length,
357
+ dropped,
358
+ requestSent: false
359
+ };
360
+ }
285
361
  const payload = {
286
362
  eventId,
287
363
  datasetDatasourceId,
@@ -303,6 +379,12 @@ var DataClient = class {
303
379
  `[@dataworks-technology/data] ingest failed: ${resp.status} \u2014 ${body}`
304
380
  );
305
381
  }
382
+ return {
383
+ acceptedCount: valid.length,
384
+ droppedCount: dropped.length,
385
+ dropped,
386
+ requestSent: true
387
+ };
306
388
  }
307
389
  /**
308
390
  * Report an error to the Dataworks Data Engine.
@@ -312,7 +394,7 @@ var DataClient = class {
312
394
  * @see https://data-sdk-docs.dataworks.live/error-reporting
313
395
  */
314
396
  async reportError(error) {
315
- this.requireAuth();
397
+ this.requireIngestAuth();
316
398
  const resp = await this.fetchWithAutoRefresh(
317
399
  (accessToken) => fetch(this.config.errorUrl, {
318
400
  method: "POST",
@@ -351,7 +433,10 @@ var DataClient = class {
351
433
  * @see https://data-sdk-docs.dataworks.live/real-time-subscriptions
352
434
  */
353
435
  subscribe(channel, onEvent, onError) {
354
- this.requireAuth();
436
+ const apiKey = this.config.apiKey?.trim();
437
+ if (!this.credentials && !apiKey) {
438
+ this.requireAuth();
439
+ }
355
440
  if (typeof WebSocket === "undefined") {
356
441
  throw new Error(
357
442
  "[@dataworks-technology/data] WebSocket is not available. Use Node >= 22, Bun, or a browser environment. For older Node versions, install a WebSocket polyfill (e.g. ws) and assign it to globalThis.WebSocket."
@@ -365,20 +450,26 @@ var DataClient = class {
365
450
  const channelPath = channel.startsWith("/") ? channel : `/${channel}`;
366
451
  return createAutoRefreshingSubscription({
367
452
  refreshAuth: async () => {
368
- await this.refreshSession();
453
+ if (this.credentials) {
454
+ await this.refreshSession();
455
+ }
369
456
  },
370
457
  onReconnectError: (error) => {
371
- onError?.(error);
458
+ onError?.(
459
+ createSubscriptionError(error.message, "RECONNECT_FAILED", error)
460
+ );
372
461
  },
373
462
  connect: ({ onUnexpectedClose }) => {
374
463
  const url = new URL(this.config.realtimeUrl);
375
464
  url.pathname = "/event/realtime";
376
465
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
377
466
  const host = new URL(this.config.realtimeUrl).host;
378
- const authPayload = JSON.stringify({
379
- Authorization: this.credentials.accessToken,
380
- host
467
+ const resolvedAuth = resolveEventsAuth({
468
+ userToken: this.credentials?.accessToken,
469
+ apiKey: this.config.apiKey?.trim()
381
470
  });
471
+ const realtimeAuthHeader = buildEventsAuthHeader(resolvedAuth, host);
472
+ const authPayload = JSON.stringify(realtimeAuthHeader);
382
473
  const authHeader = toBase64Url(authPayload);
383
474
  const ws = new WebSocket(url.toString(), [
384
475
  "aws-appsync-event-ws",
@@ -396,8 +487,9 @@ var DataClient = class {
396
487
  });
397
488
  ws.addEventListener("error", () => {
398
489
  onError?.(
399
- new Error(
400
- "[@dataworks-technology/data] WebSocket connection error"
490
+ createSubscriptionError(
491
+ "[@dataworks-technology/data] WebSocket connection error",
492
+ "CONNECTION_ERROR"
401
493
  )
402
494
  );
403
495
  });
@@ -407,8 +499,9 @@ var DataClient = class {
407
499
  msg = JSON.parse(String(event.data));
408
500
  } catch {
409
501
  onError?.(
410
- new Error(
411
- "[@dataworks-technology/data] Received malformed message from AppSync"
502
+ createSubscriptionError(
503
+ "[@dataworks-technology/data] Received malformed message from AppSync",
504
+ "PARSE_ERROR"
412
505
  )
413
506
  );
414
507
  return;
@@ -419,10 +512,7 @@ var DataClient = class {
419
512
  type: "subscribe",
420
513
  id: crypto.randomUUID(),
421
514
  channel: channelPath,
422
- authorization: {
423
- Authorization: this.credentials.accessToken,
424
- host
425
- }
515
+ authorization: realtimeAuthHeader
426
516
  })
427
517
  );
428
518
  } else if (msg.type === "data") {
@@ -432,21 +522,31 @@ var DataClient = class {
432
522
  onEvent(JSON.parse(String(raw)));
433
523
  } catch {
434
524
  onError?.(
435
- new Error(
436
- "[@dataworks-technology/data] Received malformed event payload from AppSync"
525
+ createSubscriptionError(
526
+ "[@dataworks-technology/data] Received malformed event payload from AppSync",
527
+ "PARSE_ERROR"
437
528
  )
438
529
  );
439
530
  }
440
531
  }
441
532
  } else if (msg.type === "error" || msg.type === "connection_error" || msg.type === "subscribe_error" || msg.type === "broadcast_error" || msg.type === "unsubscribe_error") {
442
533
  const msgStr = JSON.stringify(msg);
443
- if (msgStr.toLowerCase().includes("unauthor")) {
534
+ if (msgStr.toLowerCase().includes("unauthor") && !unexpectedCloseHandled) {
535
+ onError?.(
536
+ createSubscriptionError(
537
+ "[@dataworks-technology/data] Unauthorized \u2014 session may have expired, attempting to reconnect",
538
+ "UNAUTHORIZED"
539
+ )
540
+ );
444
541
  handleUnexpectedCloseOnce();
445
542
  } else {
446
543
  const errors = msg.errors;
447
544
  const errorMessage = msg.message ?? errors?.[0]?.message ?? errors?.[0]?.errorType ?? "AppSync subscription error";
448
545
  onError?.(
449
- new Error(`[@dataworks-technology/data] ${errorMessage}`)
546
+ createSubscriptionError(
547
+ `[@dataworks-technology/data] ${errorMessage}`,
548
+ "SUBSCRIPTION_ERROR"
549
+ )
450
550
  );
451
551
  }
452
552
  }
@@ -481,6 +581,16 @@ var DataClient = class {
481
581
  );
482
582
  }
483
583
  }
584
+ /**
585
+ * @internal Guard for ingest-only operations (ingest, reportError). Throws the
586
+ * canonical SDK ingest auth error so the message is consistent with the lower-level
587
+ * `@dataworks/sdk/client` HttpClient guard.
588
+ */
589
+ requireIngestAuth() {
590
+ if (!this.credentials) {
591
+ throw new Error(`[@dataworks-technology/data] ${INGEST_AUTH_ERROR}`);
592
+ }
593
+ }
484
594
  async fetchWithAutoRefresh(makeRequest) {
485
595
  this.requireAuth();
486
596
  let response = await makeRequest(this.credentials.accessToken);
@@ -531,6 +641,7 @@ DataClient.filterValidMetrics = filterValidMetrics2;
531
641
  // Annotate the CommonJS export names for ESM import in node:
532
642
  0 && (module.exports = {
533
643
  DataClient,
644
+ MetricValidationError,
534
645
  filterValidMetrics,
535
646
  validateMetric
536
647
  });