@drakkar.software/sunglasses-adapter-starfish 0.6.0 → 0.8.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/dist/index.d.mts CHANGED
@@ -1,120 +1,161 @@
1
- import { IAnalyticsAdapter, StarfishAdapterConfig, SunglassesEvent, CleanupConfig } from '@drakkar.software/sunglasses-core';
2
- export { CleanupConfig, StarfishAdapterConfig } from '@drakkar.software/sunglasses-core';
1
+ import { IAnalyticsAdapter, SunglassesEvent } from '@drakkar.software/sunglasses-core';
3
2
 
4
3
  /**
5
- * IAnalyticsAdapter that syncs events to a Starfish document-sync server.
4
+ * SunGlasses Starfish analytics adapter.
6
5
  *
7
- * ## Standard mode (default)
8
- * Protocol: pull merge push with optimistic locking.
9
- * 1. GET /pull/{path} → { data: StarfishEventDocument, hash: string }
10
- * 2. Merge incoming events into the document (dedup by messageId)
11
- * 3. POST /push/{path} → { data: updatedDoc, baseHash: hash }
12
- * 4. On 409 Conflict (optimistic locking): pull again, re-merge, re-push
13
- * (iterative, not recursive, to prevent stack overflow under high contention)
6
+ * Implements `IAnalyticsAdapter`: on each flush, maps the event batch to flat
7
+ * rows and pushes them as a JSON body to a Starfish events collection.
8
+ * The `starfish-events` server plugin intercepts the push and encodes Parquet
9
+ * on the server side no Parquet dependency on the client.
14
10
  *
15
- * ## Push-only mode (`pushOnly: true`)
16
- * Skips the pull step entirely — events are pushed as a fresh document each time.
17
- * Use for Starfish collections with `queueOnly: true` where pull always returns
18
- * empty data and optimistic locking is not needed.
19
- * On failure the adapter throws, keeping events in the local queue for retry.
11
+ * ## Transport
20
12
  *
21
- * ## Rotating path mode (`rotatePathOnSuccess: true`)
22
- * Each successful push creates a **new** Starfish document with an
23
- * auto-incrementing path suffix (e.g. `events-0001`, `events-0002`…).
24
- * - No pull step needed the new document only contains this batch's events
25
- * - No growing single document — each file stays small
26
- * - Requires `pathStorage` in config to persist the generation counter
27
- * - Combine with `enableLocalArchive: true` to keep a local copy of all events
13
+ * Uses an injected `StarfishPushClient` (structural type) so this package has
14
+ * **no runtime dependency on `@drakkar.software/starfish-client`** avoiding
15
+ * an ESM-only/unpublished-alpha dep conflict. Construct a real `StarfishClient`
16
+ * externally and pass it in via `config.client`.
28
17
  *
29
- * The storage path template supports `{identity}` as a placeholder, replaced
30
- * with `distinctId ?? anonymousId` from the first event in the batch.
18
+ * ## At-least-once delivery
31
19
  *
32
- * @see https://github.com/Drakkar-Software/Starfish
20
+ * `send()` throws on failure. SunglassesCore treats an adapter throw as a
21
+ * transient error and keeps the batch in the local queue for the next flush
22
+ * cycle — equivalent to the old `pushOnly` semantics.
23
+ *
24
+ * ## Privacy
25
+ *
26
+ * The SDK's consent gate and PiiSanitizer have already run before `send()` is
27
+ * called. This adapter must never log `distinct_id`, `properties`, or `context`
28
+ * (log counts only).
33
29
  */
34
- declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
35
- private readonly serverUrl;
36
- private readonly storagePath;
37
- private readonly authToken;
38
- private readonly maxRetries;
39
- private readonly timeoutMs;
40
- private readonly pushOnly;
41
- private readonly rotatePathOnSuccess;
42
- private readonly pathStorage;
43
- constructor(config: StarfishAdapterConfig & {
44
- timeoutMs?: number;
45
- });
46
- send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
47
- reset(): Promise<void>;
48
- shutdown(): Promise<void>;
30
+
31
+ /**
32
+ * Minimal structural interface satisfied by `StarfishClient.push`.
33
+ * Declare it here so no import of `@drakkar.software/starfish-client` is needed.
34
+ */
35
+ interface StarfishPushClient {
49
36
  /**
50
- * Prune old events from the Starfish document after a successful flush.
51
- * Called by SunglassesCore when `cleanupAfterFlush` is configured.
52
- * Only applicable in standard (non-rotating) mode.
37
+ * Push a JSON document to a Starfish collection path.
38
+ * Must throw (or reject) on non-2xx so SunglassesCore retries.
39
+ *
40
+ * @param path Logical storage path (without the `/push/` prefix).
41
+ * @param data Document body — `{ events: StarfishEventRow[] }`.
42
+ * @param baseHash Pass `null` for "must not exist" (unique path per batch).
53
43
  */
54
- cleanupAfterFlush(delivered: ReadonlyArray<SunglassesEvent>, config: CleanupConfig): Promise<void>;
55
- private sendMerge;
44
+ push(path: string, data: unknown, baseHash: string | null): Promise<unknown>;
45
+ }
46
+ /** Configuration for {@link StarfishAnalyticsAdapter}. */
47
+ interface StarfishAdapterConfig {
56
48
  /**
57
- * Push a fresh document directly, no pull, no merge, no conflict detection.
58
- * Throws on failure so SunglassesCore keeps events in queue for retry.
49
+ * A `StarfishClient` instance (or any object implementing `push`).
50
+ * Construct it with `{ baseUrl: "https://..." }` and no `capProvider` when
51
+ * targeting a `write: "public"` collection.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * import { StarfishClient } from "@drakkar.software/starfish-client"
56
+ * const client = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
57
+ * new StarfishAnalyticsAdapter({ client, app: "my-app" })
58
+ * ```
59
59
  */
60
- private sendPushOnly;
60
+ client: StarfishPushClient;
61
61
  /**
62
- * Push events to a new Starfish document each time.
63
- *
64
- * Path format: `{baseResolved}-{generation padded to 4 digits}`
65
- * e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
62
+ * Application/workspace identifier embedded in the storage path.
63
+ * Fills the `{app}` placeholder in `pathTemplate`.
64
+ * Keep it short and URL-safe (no `/` or spaces).
65
+ */
66
+ app: string;
67
+ /**
68
+ * Storage-path template. Defaults to `"events/{app}/{batchId}"`.
69
+ * `{app}` → `config.app`; `{batchId}` → a UUID v4 generated per flush.
66
70
  *
67
- * The generation is persisted to `pathStorage` so it survives app restarts.
68
- * On success: advance generation. On failure: keep current generation (retry next flush).
71
+ * The server plugin appends `.parquet` automatically, so omit it here.
69
72
  */
70
- private sendRotating;
71
- private loadGeneration;
72
- private saveGeneration;
73
- private pull;
74
- private push;
75
- private buildHeaders;
73
+ pathTemplate?: string;
76
74
  }
77
-
78
75
  /**
79
- * The document structure stored at the Starfish path.
80
- * Starfish is a document-sync system; we maintain a rolling JSON document
81
- * that accumulates analytics events for each identity.
76
+ * SunGlasses analytics adapter that delivers event batches to a Starfish
77
+ * events collection, where the `starfish-events` plugin encodes them as
78
+ * Parquet and writes to S3.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * import { SunglassesCore } from "@drakkar.software/sunglasses-core"
83
+ * import { StarfishAnalyticsAdapter } from "@drakkar.software/sunglasses-adapter-starfish"
84
+ * import { StarfishClient } from "@drakkar.software/starfish-client"
85
+ *
86
+ * const sfClient = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
87
+ *
88
+ * const sg = await SunglassesCore.create({
89
+ * storage: myStorage,
90
+ * adapters: [
91
+ * new StarfishAnalyticsAdapter({ client: sfClient, app: "my-app" }),
92
+ * ],
93
+ * })
94
+ * ```
82
95
  */
83
- interface StarfishEventDocument {
84
- /** All events recorded for this identity, ordered by timestamp. */
85
- events: SunglassesEvent[];
86
- /** ISO-8601 timestamp of the last document update. */
87
- lastUpdated: string;
88
- /** Schema version for future migrations. */
89
- version: '1';
96
+ declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
97
+ private readonly client;
98
+ private readonly app;
99
+ private readonly pathTemplate;
100
+ constructor(config: StarfishAdapterConfig);
101
+ private resolvePath;
102
+ /**
103
+ * Push a batch of events to the Starfish events collection.
104
+ *
105
+ * A unique `batchId` is generated per call, producing a unique storage path
106
+ * (one Parquet file per flush). `baseHash: null` signals "must not exist" to
107
+ * Starfish, ensuring no conflict on the unique path.
108
+ *
109
+ * Throws on failure — SunglassesCore keeps the batch in the local queue.
110
+ * Never logs event contents (distinct_id, properties, context).
111
+ */
112
+ send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
90
113
  }
114
+
91
115
  /**
92
- * Creates an empty Starfish event document.
93
- */
94
- declare function createEmptyDocument(): StarfishEventDocument;
95
- /**
96
- * Merge new events into an existing document, de-duplicating by messageId.
97
- * Remote document takes precedence for metadata fields.
116
+ * Mapping utilities: SunglassesEvent the flat EventRow shape that
117
+ * the starfish-events server plugin expects and encodes as Parquet columns.
118
+ *
119
+ * The column schema matches apps/ingest-server/src/schema.ts so a DuckDB
120
+ * query is identical regardless of which backend delivered the data.
121
+ *
122
+ * Privacy: distinct_id, properties, and context are mapped but must never
123
+ * be logged (they are opaque values that the SDK's PiiSanitizer already
124
+ * processed before the batch was handed to the adapter).
98
125
  */
99
- declare function mergeEvents(remote: StarfishEventDocument, incoming: ReadonlyArray<SunglassesEvent>): StarfishEventDocument;
126
+
100
127
  /**
101
- * Resolve the Starfish storage path for a given identity.
102
- * Replaces `{identity}` in the template with the resolved identity value.
103
- *
104
- * @example
105
- * resolveStoragePath('analytics/{identity}/events', 'user-123')
106
- * // → 'analytics/user-123/events'
128
+ * Flat row shape sent to the Starfish events collection.
129
+ * All fields are strings; properties and context are JSON-serialized.
107
130
  */
108
- declare function resolveStoragePath(template: string, identity: string): string;
131
+ interface StarfishEventRow {
132
+ /** EventType: 'capture' | 'screen' | 'identify' | 'alias' | 'group' */
133
+ event_type: string;
134
+ /** Human-readable event name, e.g. 'button_clicked' or '$screen'. */
135
+ event: string;
136
+ /** Resolved user identifier (hashed if anonymizeUserId is set). */
137
+ distinct_id: string;
138
+ /** Stable device UUID — safe for DAU / retention analysis. */
139
+ anonymous_id: string;
140
+ /** ISO-8601 UTC event timestamp. */
141
+ ts: string;
142
+ /** UUID v4 — use for deduplication. */
143
+ message_id: string;
144
+ /** JSON-serialized event properties (already PII-sanitized by the SDK). */
145
+ properties: string;
146
+ /** JSON-serialized EventContext (library/platform/app metadata). */
147
+ context: string;
148
+ /** 'YYYY-MM-DD' derived from ts — matches the Parquet partition key. */
149
+ dt: string;
150
+ }
109
151
  /**
110
- * Prune a Starfish event document according to cleanup configuration.
111
- *
112
- * Applied rules (in order):
113
- * 1. Remove events older than `maxAgeMs` milliseconds.
114
- * 2. If `maxEventsPerIdentity` is set (> 0), keep only the most recent N events.
152
+ * Map a single SunglassesEvent to the flat row format expected by the
153
+ * starfish-events plugin.
115
154
  *
116
- * Returns a new documentdoes not mutate the input.
155
+ * `received_at` is intentionally omittedthe server plugin stamps it at
156
+ * ingest time so it reflects when the event landed on the server, not when
157
+ * the client serialised the batch.
117
158
  */
118
- declare function pruneDocument(doc: StarfishEventDocument, config: CleanupConfig): StarfishEventDocument;
159
+ declare function toStarfishRow(e: SunglassesEvent): StarfishEventRow;
119
160
 
120
- export { StarfishAnalyticsAdapter, type StarfishEventDocument, createEmptyDocument, mergeEvents, pruneDocument, resolveStoragePath };
161
+ export { type StarfishAdapterConfig, StarfishAnalyticsAdapter, type StarfishEventRow, type StarfishPushClient, toStarfishRow };
package/dist/index.d.ts CHANGED
@@ -1,120 +1,161 @@
1
- import { IAnalyticsAdapter, StarfishAdapterConfig, SunglassesEvent, CleanupConfig } from '@drakkar.software/sunglasses-core';
2
- export { CleanupConfig, StarfishAdapterConfig } from '@drakkar.software/sunglasses-core';
1
+ import { IAnalyticsAdapter, SunglassesEvent } from '@drakkar.software/sunglasses-core';
3
2
 
4
3
  /**
5
- * IAnalyticsAdapter that syncs events to a Starfish document-sync server.
4
+ * SunGlasses Starfish analytics adapter.
6
5
  *
7
- * ## Standard mode (default)
8
- * Protocol: pull merge push with optimistic locking.
9
- * 1. GET /pull/{path} → { data: StarfishEventDocument, hash: string }
10
- * 2. Merge incoming events into the document (dedup by messageId)
11
- * 3. POST /push/{path} → { data: updatedDoc, baseHash: hash }
12
- * 4. On 409 Conflict (optimistic locking): pull again, re-merge, re-push
13
- * (iterative, not recursive, to prevent stack overflow under high contention)
6
+ * Implements `IAnalyticsAdapter`: on each flush, maps the event batch to flat
7
+ * rows and pushes them as a JSON body to a Starfish events collection.
8
+ * The `starfish-events` server plugin intercepts the push and encodes Parquet
9
+ * on the server side no Parquet dependency on the client.
14
10
  *
15
- * ## Push-only mode (`pushOnly: true`)
16
- * Skips the pull step entirely — events are pushed as a fresh document each time.
17
- * Use for Starfish collections with `queueOnly: true` where pull always returns
18
- * empty data and optimistic locking is not needed.
19
- * On failure the adapter throws, keeping events in the local queue for retry.
11
+ * ## Transport
20
12
  *
21
- * ## Rotating path mode (`rotatePathOnSuccess: true`)
22
- * Each successful push creates a **new** Starfish document with an
23
- * auto-incrementing path suffix (e.g. `events-0001`, `events-0002`…).
24
- * - No pull step needed the new document only contains this batch's events
25
- * - No growing single document — each file stays small
26
- * - Requires `pathStorage` in config to persist the generation counter
27
- * - Combine with `enableLocalArchive: true` to keep a local copy of all events
13
+ * Uses an injected `StarfishPushClient` (structural type) so this package has
14
+ * **no runtime dependency on `@drakkar.software/starfish-client`** avoiding
15
+ * an ESM-only/unpublished-alpha dep conflict. Construct a real `StarfishClient`
16
+ * externally and pass it in via `config.client`.
28
17
  *
29
- * The storage path template supports `{identity}` as a placeholder, replaced
30
- * with `distinctId ?? anonymousId` from the first event in the batch.
18
+ * ## At-least-once delivery
31
19
  *
32
- * @see https://github.com/Drakkar-Software/Starfish
20
+ * `send()` throws on failure. SunglassesCore treats an adapter throw as a
21
+ * transient error and keeps the batch in the local queue for the next flush
22
+ * cycle — equivalent to the old `pushOnly` semantics.
23
+ *
24
+ * ## Privacy
25
+ *
26
+ * The SDK's consent gate and PiiSanitizer have already run before `send()` is
27
+ * called. This adapter must never log `distinct_id`, `properties`, or `context`
28
+ * (log counts only).
33
29
  */
34
- declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
35
- private readonly serverUrl;
36
- private readonly storagePath;
37
- private readonly authToken;
38
- private readonly maxRetries;
39
- private readonly timeoutMs;
40
- private readonly pushOnly;
41
- private readonly rotatePathOnSuccess;
42
- private readonly pathStorage;
43
- constructor(config: StarfishAdapterConfig & {
44
- timeoutMs?: number;
45
- });
46
- send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
47
- reset(): Promise<void>;
48
- shutdown(): Promise<void>;
30
+
31
+ /**
32
+ * Minimal structural interface satisfied by `StarfishClient.push`.
33
+ * Declare it here so no import of `@drakkar.software/starfish-client` is needed.
34
+ */
35
+ interface StarfishPushClient {
49
36
  /**
50
- * Prune old events from the Starfish document after a successful flush.
51
- * Called by SunglassesCore when `cleanupAfterFlush` is configured.
52
- * Only applicable in standard (non-rotating) mode.
37
+ * Push a JSON document to a Starfish collection path.
38
+ * Must throw (or reject) on non-2xx so SunglassesCore retries.
39
+ *
40
+ * @param path Logical storage path (without the `/push/` prefix).
41
+ * @param data Document body — `{ events: StarfishEventRow[] }`.
42
+ * @param baseHash Pass `null` for "must not exist" (unique path per batch).
53
43
  */
54
- cleanupAfterFlush(delivered: ReadonlyArray<SunglassesEvent>, config: CleanupConfig): Promise<void>;
55
- private sendMerge;
44
+ push(path: string, data: unknown, baseHash: string | null): Promise<unknown>;
45
+ }
46
+ /** Configuration for {@link StarfishAnalyticsAdapter}. */
47
+ interface StarfishAdapterConfig {
56
48
  /**
57
- * Push a fresh document directly, no pull, no merge, no conflict detection.
58
- * Throws on failure so SunglassesCore keeps events in queue for retry.
49
+ * A `StarfishClient` instance (or any object implementing `push`).
50
+ * Construct it with `{ baseUrl: "https://..." }` and no `capProvider` when
51
+ * targeting a `write: "public"` collection.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * import { StarfishClient } from "@drakkar.software/starfish-client"
56
+ * const client = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
57
+ * new StarfishAnalyticsAdapter({ client, app: "my-app" })
58
+ * ```
59
59
  */
60
- private sendPushOnly;
60
+ client: StarfishPushClient;
61
61
  /**
62
- * Push events to a new Starfish document each time.
63
- *
64
- * Path format: `{baseResolved}-{generation padded to 4 digits}`
65
- * e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
62
+ * Application/workspace identifier embedded in the storage path.
63
+ * Fills the `{app}` placeholder in `pathTemplate`.
64
+ * Keep it short and URL-safe (no `/` or spaces).
65
+ */
66
+ app: string;
67
+ /**
68
+ * Storage-path template. Defaults to `"events/{app}/{batchId}"`.
69
+ * `{app}` → `config.app`; `{batchId}` → a UUID v4 generated per flush.
66
70
  *
67
- * The generation is persisted to `pathStorage` so it survives app restarts.
68
- * On success: advance generation. On failure: keep current generation (retry next flush).
71
+ * The server plugin appends `.parquet` automatically, so omit it here.
69
72
  */
70
- private sendRotating;
71
- private loadGeneration;
72
- private saveGeneration;
73
- private pull;
74
- private push;
75
- private buildHeaders;
73
+ pathTemplate?: string;
76
74
  }
77
-
78
75
  /**
79
- * The document structure stored at the Starfish path.
80
- * Starfish is a document-sync system; we maintain a rolling JSON document
81
- * that accumulates analytics events for each identity.
76
+ * SunGlasses analytics adapter that delivers event batches to a Starfish
77
+ * events collection, where the `starfish-events` plugin encodes them as
78
+ * Parquet and writes to S3.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * import { SunglassesCore } from "@drakkar.software/sunglasses-core"
83
+ * import { StarfishAnalyticsAdapter } from "@drakkar.software/sunglasses-adapter-starfish"
84
+ * import { StarfishClient } from "@drakkar.software/starfish-client"
85
+ *
86
+ * const sfClient = new StarfishClient({ baseUrl: "https://sync.example.com/v1" })
87
+ *
88
+ * const sg = await SunglassesCore.create({
89
+ * storage: myStorage,
90
+ * adapters: [
91
+ * new StarfishAnalyticsAdapter({ client: sfClient, app: "my-app" }),
92
+ * ],
93
+ * })
94
+ * ```
82
95
  */
83
- interface StarfishEventDocument {
84
- /** All events recorded for this identity, ordered by timestamp. */
85
- events: SunglassesEvent[];
86
- /** ISO-8601 timestamp of the last document update. */
87
- lastUpdated: string;
88
- /** Schema version for future migrations. */
89
- version: '1';
96
+ declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
97
+ private readonly client;
98
+ private readonly app;
99
+ private readonly pathTemplate;
100
+ constructor(config: StarfishAdapterConfig);
101
+ private resolvePath;
102
+ /**
103
+ * Push a batch of events to the Starfish events collection.
104
+ *
105
+ * A unique `batchId` is generated per call, producing a unique storage path
106
+ * (one Parquet file per flush). `baseHash: null` signals "must not exist" to
107
+ * Starfish, ensuring no conflict on the unique path.
108
+ *
109
+ * Throws on failure — SunglassesCore keeps the batch in the local queue.
110
+ * Never logs event contents (distinct_id, properties, context).
111
+ */
112
+ send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
90
113
  }
114
+
91
115
  /**
92
- * Creates an empty Starfish event document.
93
- */
94
- declare function createEmptyDocument(): StarfishEventDocument;
95
- /**
96
- * Merge new events into an existing document, de-duplicating by messageId.
97
- * Remote document takes precedence for metadata fields.
116
+ * Mapping utilities: SunglassesEvent the flat EventRow shape that
117
+ * the starfish-events server plugin expects and encodes as Parquet columns.
118
+ *
119
+ * The column schema matches apps/ingest-server/src/schema.ts so a DuckDB
120
+ * query is identical regardless of which backend delivered the data.
121
+ *
122
+ * Privacy: distinct_id, properties, and context are mapped but must never
123
+ * be logged (they are opaque values that the SDK's PiiSanitizer already
124
+ * processed before the batch was handed to the adapter).
98
125
  */
99
- declare function mergeEvents(remote: StarfishEventDocument, incoming: ReadonlyArray<SunglassesEvent>): StarfishEventDocument;
126
+
100
127
  /**
101
- * Resolve the Starfish storage path for a given identity.
102
- * Replaces `{identity}` in the template with the resolved identity value.
103
- *
104
- * @example
105
- * resolveStoragePath('analytics/{identity}/events', 'user-123')
106
- * // → 'analytics/user-123/events'
128
+ * Flat row shape sent to the Starfish events collection.
129
+ * All fields are strings; properties and context are JSON-serialized.
107
130
  */
108
- declare function resolveStoragePath(template: string, identity: string): string;
131
+ interface StarfishEventRow {
132
+ /** EventType: 'capture' | 'screen' | 'identify' | 'alias' | 'group' */
133
+ event_type: string;
134
+ /** Human-readable event name, e.g. 'button_clicked' or '$screen'. */
135
+ event: string;
136
+ /** Resolved user identifier (hashed if anonymizeUserId is set). */
137
+ distinct_id: string;
138
+ /** Stable device UUID — safe for DAU / retention analysis. */
139
+ anonymous_id: string;
140
+ /** ISO-8601 UTC event timestamp. */
141
+ ts: string;
142
+ /** UUID v4 — use for deduplication. */
143
+ message_id: string;
144
+ /** JSON-serialized event properties (already PII-sanitized by the SDK). */
145
+ properties: string;
146
+ /** JSON-serialized EventContext (library/platform/app metadata). */
147
+ context: string;
148
+ /** 'YYYY-MM-DD' derived from ts — matches the Parquet partition key. */
149
+ dt: string;
150
+ }
109
151
  /**
110
- * Prune a Starfish event document according to cleanup configuration.
111
- *
112
- * Applied rules (in order):
113
- * 1. Remove events older than `maxAgeMs` milliseconds.
114
- * 2. If `maxEventsPerIdentity` is set (> 0), keep only the most recent N events.
152
+ * Map a single SunglassesEvent to the flat row format expected by the
153
+ * starfish-events plugin.
115
154
  *
116
- * Returns a new documentdoes not mutate the input.
155
+ * `received_at` is intentionally omittedthe server plugin stamps it at
156
+ * ingest time so it reflects when the event landed on the server, not when
157
+ * the client serialised the batch.
117
158
  */
118
- declare function pruneDocument(doc: StarfishEventDocument, config: CleanupConfig): StarfishEventDocument;
159
+ declare function toStarfishRow(e: SunglassesEvent): StarfishEventRow;
119
160
 
120
- export { StarfishAnalyticsAdapter, type StarfishEventDocument, createEmptyDocument, mergeEvents, pruneDocument, resolveStoragePath };
161
+ export { type StarfishAdapterConfig, StarfishAnalyticsAdapter, type StarfishEventRow, type StarfishPushClient, toStarfishRow };
package/dist/index.js CHANGED
@@ -21,276 +21,59 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  StarfishAnalyticsAdapter: () => StarfishAnalyticsAdapter,
24
- createEmptyDocument: () => createEmptyDocument,
25
- mergeEvents: () => mergeEvents,
26
- pruneDocument: () => pruneDocument,
27
- resolveStoragePath: () => resolveStoragePath
24
+ toStarfishRow: () => toStarfishRow
28
25
  });
29
26
  module.exports = __toCommonJS(index_exports);
30
27
 
28
+ // src/StarfishAnalyticsAdapter.ts
29
+ var import_sunglasses_core = require("@drakkar.software/sunglasses-core");
30
+
31
31
  // src/StarfishEventMapper.ts
32
- function createEmptyDocument() {
33
- return {
34
- events: [],
35
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
36
- version: "1"
37
- };
38
- }
39
- function mergeEvents(remote, incoming) {
40
- const existingIds = new Set(remote.events.map((e) => e.messageId));
41
- const newEvents = incoming.filter((e) => !existingIds.has(e.messageId));
32
+ function toStarfishRow(e) {
42
33
  return {
43
- ...remote,
44
- events: [...remote.events, ...newEvents],
45
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
46
- };
47
- }
48
- function resolveStoragePath(template, identity) {
49
- return template.replace("{identity}", encodeURIComponent(identity));
50
- }
51
- function pruneDocument(doc, config) {
52
- let events = [...doc.events];
53
- if (config.maxAgeMs !== void 0 && config.maxAgeMs > 0) {
54
- const cutoff = Date.now() - config.maxAgeMs;
55
- events = events.filter((e) => {
56
- const ts = new Date(e.timestamp).getTime();
57
- return !Number.isNaN(ts) && ts >= cutoff;
58
- });
59
- }
60
- const maxN = config.maxEventsPerIdentity ?? 0;
61
- if (maxN > 0 && events.length > maxN) {
62
- events = events.slice(events.length - maxN);
63
- }
64
- return {
65
- ...doc,
66
- events,
67
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
34
+ event_type: e.type,
35
+ event: e.event,
36
+ distinct_id: e.distinctId,
37
+ anonymous_id: e.anonymousId,
38
+ ts: e.timestamp,
39
+ message_id: e.messageId,
40
+ properties: JSON.stringify(e.properties),
41
+ context: JSON.stringify(e.context),
42
+ dt: e.timestamp.slice(0, 10)
68
43
  };
69
44
  }
70
45
 
71
46
  // src/StarfishAnalyticsAdapter.ts
72
- var DEFAULT_MAX_RETRIES = 3;
73
- var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
74
- var GEN_KEY_PREFIX = "sg:starfish:gen:";
47
+ var DEFAULT_PATH_TEMPLATE = "events/{app}/{batchId}";
75
48
  var StarfishAnalyticsAdapter = class {
76
49
  constructor(config) {
77
- this.serverUrl = config.serverUrl.replace(/\/$/, "");
78
- this.storagePath = config.storagePath;
79
- this.authToken = config.authToken ?? "";
80
- this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
81
- this.timeoutMs = config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
82
- this.pushOnly = config.pushOnly ?? false;
83
- this.rotatePathOnSuccess = config.rotatePathOnSuccess ?? false;
84
- this.pathStorage = config.pathStorage ?? null;
85
- if (this.rotatePathOnSuccess && !this.pathStorage) {
86
- console.warn(
87
- "[SunGlasses] StarfishAnalyticsAdapter: rotatePathOnSuccess requires pathStorage \u2014 falling back to standard mode"
88
- );
89
- }
90
- }
91
- async send(batch) {
92
- if (batch.length === 0) return;
93
- const identity = batch[0].distinctId || batch[0].anonymousId;
94
- if (batch.some((e) => (e.distinctId || e.anonymousId) !== identity)) {
95
- console.warn(
96
- "[SunGlasses] StarfishAnalyticsAdapter: batch contains events from multiple identities \u2014 all events will be written to the document for the first identity. This is unexpected."
97
- );
98
- }
99
- const baseResolved = resolveStoragePath(this.storagePath, identity);
100
- if (this.pushOnly) {
101
- return this.sendPushOnly(batch, baseResolved);
102
- }
103
- if (this.rotatePathOnSuccess && this.pathStorage) {
104
- return this.sendRotating(batch, identity, baseResolved);
105
- }
106
- return this.sendMerge(batch, baseResolved);
107
- }
108
- async reset() {
50
+ this.client = config.client;
51
+ this.app = config.app;
52
+ this.pathTemplate = config.pathTemplate ?? DEFAULT_PATH_TEMPLATE;
109
53
  }
110
- async shutdown() {
54
+ resolvePath(batchId) {
55
+ return this.pathTemplate.replace("{app}", encodeURIComponent(this.app)).replace("{batchId}", batchId);
111
56
  }
112
57
  /**
113
- * Prune old events from the Starfish document after a successful flush.
114
- * Called by SunglassesCore when `cleanupAfterFlush` is configured.
115
- * Only applicable in standard (non-rotating) mode.
116
- */
117
- async cleanupAfterFlush(delivered, config) {
118
- if (delivered.length === 0) return;
119
- if (this.rotatePathOnSuccess) return;
120
- const identity = delivered[0].distinctId || delivered[0].anonymousId;
121
- const path = resolveStoragePath(this.storagePath, identity);
122
- for (let attempt = 0; attempt < this.maxRetries; attempt++) {
123
- try {
124
- const pulled = await this.pull(path);
125
- if (!pulled) return;
126
- const pruned = pruneDocument(pulled.data, config);
127
- const pushResponse = await this.push(path, pruned, pulled.hash);
128
- if (pushResponse.status === 409) {
129
- continue;
130
- }
131
- return;
132
- } catch {
133
- if (attempt === this.maxRetries - 1) {
134
- console.warn(
135
- "[SunGlasses] StarfishAnalyticsAdapter: cleanup failed \u2014 will retry on next flush"
136
- );
137
- }
138
- }
139
- }
140
- }
141
- // ── Private: standard pull-merge-push ──────────────────────────────────────
142
- async sendMerge(batch, path) {
143
- for (let attempt = 0; attempt < this.maxRetries; attempt++) {
144
- try {
145
- const pulled = await this.pull(path);
146
- const currentDoc = pulled?.data ?? createEmptyDocument();
147
- const baseHash = pulled?.hash ?? "";
148
- const updatedDoc = mergeEvents(currentDoc, batch);
149
- const pushResponse = await this.push(path, updatedDoc, baseHash);
150
- if (pushResponse.status === 409) {
151
- continue;
152
- }
153
- if (!pushResponse.ok) {
154
- console.warn(
155
- `[SunGlasses] StarfishAnalyticsAdapter: push failed with status ${pushResponse.status} \u2014 batch discarded`
156
- );
157
- }
158
- return;
159
- } catch (err) {
160
- const isLastAttempt = attempt === this.maxRetries - 1;
161
- if (isLastAttempt) {
162
- console.warn(
163
- `[SunGlasses] StarfishAnalyticsAdapter: network error after ${this.maxRetries} attempts \u2014 batch discarded`,
164
- err
165
- );
166
- return;
167
- }
168
- }
169
- }
170
- console.warn(
171
- `[SunGlasses] StarfishAnalyticsAdapter: max retries (${this.maxRetries}) exceeded for path "${path}" \u2014 batch discarded`
172
- );
173
- }
174
- // ── Private: push-only mode (queueOnly collections) ──────────────────────
175
- /**
176
- * Push a fresh document directly, no pull, no merge, no conflict detection.
177
- * Throws on failure so SunglassesCore keeps events in queue for retry.
178
- */
179
- async sendPushOnly(batch, path) {
180
- const doc = mergeEvents(createEmptyDocument(), batch);
181
- const response = await this.push(path, doc, "");
182
- if (!response.ok) {
183
- throw new Error(
184
- `[SunGlasses] StarfishAnalyticsAdapter: push-only push failed with status ${response.status} for path "${path}"`
185
- );
186
- }
187
- }
188
- // ── Private: rotating path mode ────────────────────────────────────────────
189
- /**
190
- * Push events to a new Starfish document each time.
58
+ * Push a batch of events to the Starfish events collection.
191
59
  *
192
- * Path format: `{baseResolved}-{generation padded to 4 digits}`
193
- * e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
60
+ * A unique `batchId` is generated per call, producing a unique storage path
61
+ * (one Parquet file per flush). `baseHash: null` signals "must not exist" to
62
+ * Starfish, ensuring no conflict on the unique path.
194
63
  *
195
- * The generation is persisted to `pathStorage` so it survives app restarts.
196
- * On success: advance generation. On failure: keep current generation (retry next flush).
64
+ * Throws on failure SunglassesCore keeps the batch in the local queue.
65
+ * Never logs event contents (distinct_id, properties, context).
197
66
  */
198
- async sendRotating(batch, identity, baseResolved) {
199
- const gen = await this.loadGeneration(identity);
200
- const path = `${baseResolved}-${String(gen).padStart(4, "0")}`;
201
- const doc = mergeEvents(createEmptyDocument(), batch);
202
- try {
203
- const response = await this.push(path, doc, "");
204
- if (response.ok) {
205
- await this.saveGeneration(identity, gen + 1);
206
- } else if (response.status === 409) {
207
- await this.saveGeneration(identity, gen + 1);
208
- throw new Error(
209
- `[SunGlasses] StarfishAnalyticsAdapter: 409 conflict at rotating path "${path}" \u2014 generation advanced, will retry`
210
- );
211
- } else {
212
- console.warn(
213
- `[SunGlasses] StarfishAnalyticsAdapter: rotating push failed with status ${response.status} for path "${path}"`
214
- );
215
- }
216
- } catch (err) {
217
- console.warn(
218
- `[SunGlasses] StarfishAnalyticsAdapter: rotating push network error for path "${path}"`,
219
- err
220
- );
221
- }
222
- }
223
- async loadGeneration(identity) {
224
- if (!this.pathStorage) return 0;
225
- try {
226
- const raw = await this.pathStorage.read(`${GEN_KEY_PREFIX}${identity}`);
227
- return raw !== null ? parseInt(raw, 10) : 0;
228
- } catch {
229
- return 0;
230
- }
231
- }
232
- async saveGeneration(identity, gen) {
233
- if (!this.pathStorage) return;
234
- try {
235
- await this.pathStorage.write(`${GEN_KEY_PREFIX}${identity}`, String(gen));
236
- } catch (err) {
237
- console.warn("[SunGlasses] StarfishAnalyticsAdapter: failed to save generation counter", err);
238
- }
239
- }
240
- // ── Private: HTTP helpers ──────────────────────────────────────────────────
241
- async pull(path) {
242
- const controller = new AbortController();
243
- const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
244
- try {
245
- const response = await fetch(`${this.serverUrl}/pull/${path}`, {
246
- method: "GET",
247
- headers: this.buildHeaders(),
248
- signal: controller.signal
249
- });
250
- if (response.status === 404) {
251
- return null;
252
- }
253
- if (!response.ok) {
254
- throw new Error(`Starfish pull failed: ${response.status}`);
255
- }
256
- return response.json();
257
- } finally {
258
- clearTimeout(timeoutId);
259
- }
260
- }
261
- async push(path, doc, baseHash) {
262
- const controller = new AbortController();
263
- const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
264
- try {
265
- return await fetch(`${this.serverUrl}/push/${path}`, {
266
- method: "POST",
267
- headers: {
268
- "Content-Type": "application/json",
269
- ...this.buildHeaders()
270
- },
271
- body: JSON.stringify({
272
- data: doc,
273
- baseHash: baseHash || void 0
274
- }),
275
- signal: controller.signal
276
- });
277
- } finally {
278
- clearTimeout(timeoutId);
279
- }
280
- }
281
- buildHeaders() {
282
- const headers = {};
283
- if (this.authToken) {
284
- headers["Authorization"] = `Bearer ${this.authToken}`;
285
- }
286
- return headers;
67
+ async send(batch) {
68
+ if (batch.length === 0) return;
69
+ const batchId = (0, import_sunglasses_core.generateUUID)();
70
+ const path = this.resolvePath(batchId);
71
+ const rows = batch.map(toStarfishRow);
72
+ await this.client.push(path, { events: rows }, null);
287
73
  }
288
74
  };
289
75
  // Annotate the CommonJS export names for ESM import in node:
290
76
  0 && (module.exports = {
291
77
  StarfishAnalyticsAdapter,
292
- createEmptyDocument,
293
- mergeEvents,
294
- pruneDocument,
295
- resolveStoragePath
78
+ toStarfishRow
296
79
  });
package/dist/index.mjs CHANGED
@@ -1,265 +1,51 @@
1
+ // src/StarfishAnalyticsAdapter.ts
2
+ import { generateUUID } from "@drakkar.software/sunglasses-core";
3
+
1
4
  // src/StarfishEventMapper.ts
2
- function createEmptyDocument() {
3
- return {
4
- events: [],
5
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
6
- version: "1"
7
- };
8
- }
9
- function mergeEvents(remote, incoming) {
10
- const existingIds = new Set(remote.events.map((e) => e.messageId));
11
- const newEvents = incoming.filter((e) => !existingIds.has(e.messageId));
5
+ function toStarfishRow(e) {
12
6
  return {
13
- ...remote,
14
- events: [...remote.events, ...newEvents],
15
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
16
- };
17
- }
18
- function resolveStoragePath(template, identity) {
19
- return template.replace("{identity}", encodeURIComponent(identity));
20
- }
21
- function pruneDocument(doc, config) {
22
- let events = [...doc.events];
23
- if (config.maxAgeMs !== void 0 && config.maxAgeMs > 0) {
24
- const cutoff = Date.now() - config.maxAgeMs;
25
- events = events.filter((e) => {
26
- const ts = new Date(e.timestamp).getTime();
27
- return !Number.isNaN(ts) && ts >= cutoff;
28
- });
29
- }
30
- const maxN = config.maxEventsPerIdentity ?? 0;
31
- if (maxN > 0 && events.length > maxN) {
32
- events = events.slice(events.length - maxN);
33
- }
34
- return {
35
- ...doc,
36
- events,
37
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
7
+ event_type: e.type,
8
+ event: e.event,
9
+ distinct_id: e.distinctId,
10
+ anonymous_id: e.anonymousId,
11
+ ts: e.timestamp,
12
+ message_id: e.messageId,
13
+ properties: JSON.stringify(e.properties),
14
+ context: JSON.stringify(e.context),
15
+ dt: e.timestamp.slice(0, 10)
38
16
  };
39
17
  }
40
18
 
41
19
  // src/StarfishAnalyticsAdapter.ts
42
- var DEFAULT_MAX_RETRIES = 3;
43
- var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
44
- var GEN_KEY_PREFIX = "sg:starfish:gen:";
20
+ var DEFAULT_PATH_TEMPLATE = "events/{app}/{batchId}";
45
21
  var StarfishAnalyticsAdapter = class {
46
22
  constructor(config) {
47
- this.serverUrl = config.serverUrl.replace(/\/$/, "");
48
- this.storagePath = config.storagePath;
49
- this.authToken = config.authToken ?? "";
50
- this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
51
- this.timeoutMs = config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
52
- this.pushOnly = config.pushOnly ?? false;
53
- this.rotatePathOnSuccess = config.rotatePathOnSuccess ?? false;
54
- this.pathStorage = config.pathStorage ?? null;
55
- if (this.rotatePathOnSuccess && !this.pathStorage) {
56
- console.warn(
57
- "[SunGlasses] StarfishAnalyticsAdapter: rotatePathOnSuccess requires pathStorage \u2014 falling back to standard mode"
58
- );
59
- }
60
- }
61
- async send(batch) {
62
- if (batch.length === 0) return;
63
- const identity = batch[0].distinctId || batch[0].anonymousId;
64
- if (batch.some((e) => (e.distinctId || e.anonymousId) !== identity)) {
65
- console.warn(
66
- "[SunGlasses] StarfishAnalyticsAdapter: batch contains events from multiple identities \u2014 all events will be written to the document for the first identity. This is unexpected."
67
- );
68
- }
69
- const baseResolved = resolveStoragePath(this.storagePath, identity);
70
- if (this.pushOnly) {
71
- return this.sendPushOnly(batch, baseResolved);
72
- }
73
- if (this.rotatePathOnSuccess && this.pathStorage) {
74
- return this.sendRotating(batch, identity, baseResolved);
75
- }
76
- return this.sendMerge(batch, baseResolved);
77
- }
78
- async reset() {
23
+ this.client = config.client;
24
+ this.app = config.app;
25
+ this.pathTemplate = config.pathTemplate ?? DEFAULT_PATH_TEMPLATE;
79
26
  }
80
- async shutdown() {
27
+ resolvePath(batchId) {
28
+ return this.pathTemplate.replace("{app}", encodeURIComponent(this.app)).replace("{batchId}", batchId);
81
29
  }
82
30
  /**
83
- * Prune old events from the Starfish document after a successful flush.
84
- * Called by SunglassesCore when `cleanupAfterFlush` is configured.
85
- * Only applicable in standard (non-rotating) mode.
86
- */
87
- async cleanupAfterFlush(delivered, config) {
88
- if (delivered.length === 0) return;
89
- if (this.rotatePathOnSuccess) return;
90
- const identity = delivered[0].distinctId || delivered[0].anonymousId;
91
- const path = resolveStoragePath(this.storagePath, identity);
92
- for (let attempt = 0; attempt < this.maxRetries; attempt++) {
93
- try {
94
- const pulled = await this.pull(path);
95
- if (!pulled) return;
96
- const pruned = pruneDocument(pulled.data, config);
97
- const pushResponse = await this.push(path, pruned, pulled.hash);
98
- if (pushResponse.status === 409) {
99
- continue;
100
- }
101
- return;
102
- } catch {
103
- if (attempt === this.maxRetries - 1) {
104
- console.warn(
105
- "[SunGlasses] StarfishAnalyticsAdapter: cleanup failed \u2014 will retry on next flush"
106
- );
107
- }
108
- }
109
- }
110
- }
111
- // ── Private: standard pull-merge-push ──────────────────────────────────────
112
- async sendMerge(batch, path) {
113
- for (let attempt = 0; attempt < this.maxRetries; attempt++) {
114
- try {
115
- const pulled = await this.pull(path);
116
- const currentDoc = pulled?.data ?? createEmptyDocument();
117
- const baseHash = pulled?.hash ?? "";
118
- const updatedDoc = mergeEvents(currentDoc, batch);
119
- const pushResponse = await this.push(path, updatedDoc, baseHash);
120
- if (pushResponse.status === 409) {
121
- continue;
122
- }
123
- if (!pushResponse.ok) {
124
- console.warn(
125
- `[SunGlasses] StarfishAnalyticsAdapter: push failed with status ${pushResponse.status} \u2014 batch discarded`
126
- );
127
- }
128
- return;
129
- } catch (err) {
130
- const isLastAttempt = attempt === this.maxRetries - 1;
131
- if (isLastAttempt) {
132
- console.warn(
133
- `[SunGlasses] StarfishAnalyticsAdapter: network error after ${this.maxRetries} attempts \u2014 batch discarded`,
134
- err
135
- );
136
- return;
137
- }
138
- }
139
- }
140
- console.warn(
141
- `[SunGlasses] StarfishAnalyticsAdapter: max retries (${this.maxRetries}) exceeded for path "${path}" \u2014 batch discarded`
142
- );
143
- }
144
- // ── Private: push-only mode (queueOnly collections) ──────────────────────
145
- /**
146
- * Push a fresh document directly, no pull, no merge, no conflict detection.
147
- * Throws on failure so SunglassesCore keeps events in queue for retry.
148
- */
149
- async sendPushOnly(batch, path) {
150
- const doc = mergeEvents(createEmptyDocument(), batch);
151
- const response = await this.push(path, doc, "");
152
- if (!response.ok) {
153
- throw new Error(
154
- `[SunGlasses] StarfishAnalyticsAdapter: push-only push failed with status ${response.status} for path "${path}"`
155
- );
156
- }
157
- }
158
- // ── Private: rotating path mode ────────────────────────────────────────────
159
- /**
160
- * Push events to a new Starfish document each time.
31
+ * Push a batch of events to the Starfish events collection.
161
32
  *
162
- * Path format: `{baseResolved}-{generation padded to 4 digits}`
163
- * e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
33
+ * A unique `batchId` is generated per call, producing a unique storage path
34
+ * (one Parquet file per flush). `baseHash: null` signals "must not exist" to
35
+ * Starfish, ensuring no conflict on the unique path.
164
36
  *
165
- * The generation is persisted to `pathStorage` so it survives app restarts.
166
- * On success: advance generation. On failure: keep current generation (retry next flush).
37
+ * Throws on failure SunglassesCore keeps the batch in the local queue.
38
+ * Never logs event contents (distinct_id, properties, context).
167
39
  */
168
- async sendRotating(batch, identity, baseResolved) {
169
- const gen = await this.loadGeneration(identity);
170
- const path = `${baseResolved}-${String(gen).padStart(4, "0")}`;
171
- const doc = mergeEvents(createEmptyDocument(), batch);
172
- try {
173
- const response = await this.push(path, doc, "");
174
- if (response.ok) {
175
- await this.saveGeneration(identity, gen + 1);
176
- } else if (response.status === 409) {
177
- await this.saveGeneration(identity, gen + 1);
178
- throw new Error(
179
- `[SunGlasses] StarfishAnalyticsAdapter: 409 conflict at rotating path "${path}" \u2014 generation advanced, will retry`
180
- );
181
- } else {
182
- console.warn(
183
- `[SunGlasses] StarfishAnalyticsAdapter: rotating push failed with status ${response.status} for path "${path}"`
184
- );
185
- }
186
- } catch (err) {
187
- console.warn(
188
- `[SunGlasses] StarfishAnalyticsAdapter: rotating push network error for path "${path}"`,
189
- err
190
- );
191
- }
192
- }
193
- async loadGeneration(identity) {
194
- if (!this.pathStorage) return 0;
195
- try {
196
- const raw = await this.pathStorage.read(`${GEN_KEY_PREFIX}${identity}`);
197
- return raw !== null ? parseInt(raw, 10) : 0;
198
- } catch {
199
- return 0;
200
- }
201
- }
202
- async saveGeneration(identity, gen) {
203
- if (!this.pathStorage) return;
204
- try {
205
- await this.pathStorage.write(`${GEN_KEY_PREFIX}${identity}`, String(gen));
206
- } catch (err) {
207
- console.warn("[SunGlasses] StarfishAnalyticsAdapter: failed to save generation counter", err);
208
- }
209
- }
210
- // ── Private: HTTP helpers ──────────────────────────────────────────────────
211
- async pull(path) {
212
- const controller = new AbortController();
213
- const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
214
- try {
215
- const response = await fetch(`${this.serverUrl}/pull/${path}`, {
216
- method: "GET",
217
- headers: this.buildHeaders(),
218
- signal: controller.signal
219
- });
220
- if (response.status === 404) {
221
- return null;
222
- }
223
- if (!response.ok) {
224
- throw new Error(`Starfish pull failed: ${response.status}`);
225
- }
226
- return response.json();
227
- } finally {
228
- clearTimeout(timeoutId);
229
- }
230
- }
231
- async push(path, doc, baseHash) {
232
- const controller = new AbortController();
233
- const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
234
- try {
235
- return await fetch(`${this.serverUrl}/push/${path}`, {
236
- method: "POST",
237
- headers: {
238
- "Content-Type": "application/json",
239
- ...this.buildHeaders()
240
- },
241
- body: JSON.stringify({
242
- data: doc,
243
- baseHash: baseHash || void 0
244
- }),
245
- signal: controller.signal
246
- });
247
- } finally {
248
- clearTimeout(timeoutId);
249
- }
250
- }
251
- buildHeaders() {
252
- const headers = {};
253
- if (this.authToken) {
254
- headers["Authorization"] = `Bearer ${this.authToken}`;
255
- }
256
- return headers;
40
+ async send(batch) {
41
+ if (batch.length === 0) return;
42
+ const batchId = generateUUID();
43
+ const path = this.resolvePath(batchId);
44
+ const rows = batch.map(toStarfishRow);
45
+ await this.client.push(path, { events: rows }, null);
257
46
  }
258
47
  };
259
48
  export {
260
49
  StarfishAnalyticsAdapter,
261
- createEmptyDocument,
262
- mergeEvents,
263
- pruneDocument,
264
- resolveStoragePath
50
+ toStarfishRow
265
51
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@drakkar.software/sunglasses-adapter-starfish",
3
- "version": "0.6.0",
4
- "description": "Starfish document-sync adapter for SunGlasses (Drakkar-Software/Starfish)",
3
+ "version": "0.8.0",
4
+ "description": "Starfish adapter for SunGlasses — pushes event batches as JSON to a Starfish events collection (server encodes to Parquet)",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
@@ -16,7 +16,7 @@
16
16
  "dist"
17
17
  ],
18
18
  "dependencies": {
19
- "@drakkar.software/sunglasses-core": "0.6.0"
19
+ "@drakkar.software/sunglasses-core": "0.8.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "tsup": "^8.3.5",