@drakkar.software/sunglasses-adapter-starfish 0.1.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 +108 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +278 -0
- package/dist/index.mjs +247 -0
- package/package.json +35 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { IAnalyticsAdapter, StarfishAdapterConfig, SunglassesEvent, CleanupConfig } from '@drakkar.software/sunglasses-core';
|
|
2
|
+
export { CleanupConfig, StarfishAdapterConfig } from '@drakkar.software/sunglasses-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* IAnalyticsAdapter that syncs events to a Starfish document-sync server.
|
|
6
|
+
*
|
|
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)
|
|
14
|
+
*
|
|
15
|
+
* ## Rotating path mode (`rotatePathOnSuccess: true`)
|
|
16
|
+
* Each successful push creates a **new** Starfish document with an
|
|
17
|
+
* auto-incrementing path suffix (e.g. `events-0001`, `events-0002`…).
|
|
18
|
+
* - No pull step needed — the new document only contains this batch's events
|
|
19
|
+
* - No growing single document — each file stays small
|
|
20
|
+
* - Requires `pathStorage` in config to persist the generation counter
|
|
21
|
+
* - Combine with `enableLocalArchive: true` to keep a local copy of all events
|
|
22
|
+
*
|
|
23
|
+
* The storage path template supports `{identity}` as a placeholder, replaced
|
|
24
|
+
* with `distinctId ?? anonymousId` from the first event in the batch.
|
|
25
|
+
*
|
|
26
|
+
* @see https://github.com/Drakkar-Software/Starfish
|
|
27
|
+
*/
|
|
28
|
+
declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
|
|
29
|
+
private readonly serverUrl;
|
|
30
|
+
private readonly storagePath;
|
|
31
|
+
private readonly authToken;
|
|
32
|
+
private readonly maxRetries;
|
|
33
|
+
private readonly timeoutMs;
|
|
34
|
+
private readonly rotatePathOnSuccess;
|
|
35
|
+
private readonly pathStorage;
|
|
36
|
+
constructor(config: StarfishAdapterConfig & {
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
});
|
|
39
|
+
send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
|
|
40
|
+
reset(): Promise<void>;
|
|
41
|
+
shutdown(): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Prune old events from the Starfish document after a successful flush.
|
|
44
|
+
* Called by SunglassesCore when `cleanupAfterFlush` is configured.
|
|
45
|
+
* Only applicable in standard (non-rotating) mode.
|
|
46
|
+
*/
|
|
47
|
+
cleanupAfterFlush(delivered: ReadonlyArray<SunglassesEvent>, config: CleanupConfig): Promise<void>;
|
|
48
|
+
private sendMerge;
|
|
49
|
+
/**
|
|
50
|
+
* Push events to a new Starfish document each time.
|
|
51
|
+
*
|
|
52
|
+
* Path format: `{baseResolved}-{generation padded to 4 digits}`
|
|
53
|
+
* e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
|
|
54
|
+
*
|
|
55
|
+
* The generation is persisted to `pathStorage` so it survives app restarts.
|
|
56
|
+
* On success: advance generation. On failure: keep current generation (retry next flush).
|
|
57
|
+
*/
|
|
58
|
+
private sendRotating;
|
|
59
|
+
private loadGeneration;
|
|
60
|
+
private saveGeneration;
|
|
61
|
+
private pull;
|
|
62
|
+
private push;
|
|
63
|
+
private buildHeaders;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The document structure stored at the Starfish path.
|
|
68
|
+
* Starfish is a document-sync system; we maintain a rolling JSON document
|
|
69
|
+
* that accumulates analytics events for each identity.
|
|
70
|
+
*/
|
|
71
|
+
interface StarfishEventDocument {
|
|
72
|
+
/** All events recorded for this identity, ordered by timestamp. */
|
|
73
|
+
events: SunglassesEvent[];
|
|
74
|
+
/** ISO-8601 timestamp of the last document update. */
|
|
75
|
+
lastUpdated: string;
|
|
76
|
+
/** Schema version for future migrations. */
|
|
77
|
+
version: '1';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Creates an empty Starfish event document.
|
|
81
|
+
*/
|
|
82
|
+
declare function createEmptyDocument(): StarfishEventDocument;
|
|
83
|
+
/**
|
|
84
|
+
* Merge new events into an existing document, de-duplicating by messageId.
|
|
85
|
+
* Remote document takes precedence for metadata fields.
|
|
86
|
+
*/
|
|
87
|
+
declare function mergeEvents(remote: StarfishEventDocument, incoming: ReadonlyArray<SunglassesEvent>): StarfishEventDocument;
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the Starfish storage path for a given identity.
|
|
90
|
+
* Replaces `{identity}` in the template with the resolved identity value.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* resolveStoragePath('analytics/{identity}/events', 'user-123')
|
|
94
|
+
* // → 'analytics/user-123/events'
|
|
95
|
+
*/
|
|
96
|
+
declare function resolveStoragePath(template: string, identity: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Prune a Starfish event document according to cleanup configuration.
|
|
99
|
+
*
|
|
100
|
+
* Applied rules (in order):
|
|
101
|
+
* 1. Remove events older than `maxAgeMs` milliseconds.
|
|
102
|
+
* 2. If `maxEventsPerIdentity` is set (> 0), keep only the most recent N events.
|
|
103
|
+
*
|
|
104
|
+
* Returns a new document — does not mutate the input.
|
|
105
|
+
*/
|
|
106
|
+
declare function pruneDocument(doc: StarfishEventDocument, config: CleanupConfig): StarfishEventDocument;
|
|
107
|
+
|
|
108
|
+
export { StarfishAnalyticsAdapter, type StarfishEventDocument, createEmptyDocument, mergeEvents, pruneDocument, resolveStoragePath };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { IAnalyticsAdapter, StarfishAdapterConfig, SunglassesEvent, CleanupConfig } from '@drakkar.software/sunglasses-core';
|
|
2
|
+
export { CleanupConfig, StarfishAdapterConfig } from '@drakkar.software/sunglasses-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* IAnalyticsAdapter that syncs events to a Starfish document-sync server.
|
|
6
|
+
*
|
|
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)
|
|
14
|
+
*
|
|
15
|
+
* ## Rotating path mode (`rotatePathOnSuccess: true`)
|
|
16
|
+
* Each successful push creates a **new** Starfish document with an
|
|
17
|
+
* auto-incrementing path suffix (e.g. `events-0001`, `events-0002`…).
|
|
18
|
+
* - No pull step needed — the new document only contains this batch's events
|
|
19
|
+
* - No growing single document — each file stays small
|
|
20
|
+
* - Requires `pathStorage` in config to persist the generation counter
|
|
21
|
+
* - Combine with `enableLocalArchive: true` to keep a local copy of all events
|
|
22
|
+
*
|
|
23
|
+
* The storage path template supports `{identity}` as a placeholder, replaced
|
|
24
|
+
* with `distinctId ?? anonymousId` from the first event in the batch.
|
|
25
|
+
*
|
|
26
|
+
* @see https://github.com/Drakkar-Software/Starfish
|
|
27
|
+
*/
|
|
28
|
+
declare class StarfishAnalyticsAdapter implements IAnalyticsAdapter {
|
|
29
|
+
private readonly serverUrl;
|
|
30
|
+
private readonly storagePath;
|
|
31
|
+
private readonly authToken;
|
|
32
|
+
private readonly maxRetries;
|
|
33
|
+
private readonly timeoutMs;
|
|
34
|
+
private readonly rotatePathOnSuccess;
|
|
35
|
+
private readonly pathStorage;
|
|
36
|
+
constructor(config: StarfishAdapterConfig & {
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
});
|
|
39
|
+
send(batch: ReadonlyArray<SunglassesEvent>): Promise<void>;
|
|
40
|
+
reset(): Promise<void>;
|
|
41
|
+
shutdown(): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Prune old events from the Starfish document after a successful flush.
|
|
44
|
+
* Called by SunglassesCore when `cleanupAfterFlush` is configured.
|
|
45
|
+
* Only applicable in standard (non-rotating) mode.
|
|
46
|
+
*/
|
|
47
|
+
cleanupAfterFlush(delivered: ReadonlyArray<SunglassesEvent>, config: CleanupConfig): Promise<void>;
|
|
48
|
+
private sendMerge;
|
|
49
|
+
/**
|
|
50
|
+
* Push events to a new Starfish document each time.
|
|
51
|
+
*
|
|
52
|
+
* Path format: `{baseResolved}-{generation padded to 4 digits}`
|
|
53
|
+
* e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
|
|
54
|
+
*
|
|
55
|
+
* The generation is persisted to `pathStorage` so it survives app restarts.
|
|
56
|
+
* On success: advance generation. On failure: keep current generation (retry next flush).
|
|
57
|
+
*/
|
|
58
|
+
private sendRotating;
|
|
59
|
+
private loadGeneration;
|
|
60
|
+
private saveGeneration;
|
|
61
|
+
private pull;
|
|
62
|
+
private push;
|
|
63
|
+
private buildHeaders;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The document structure stored at the Starfish path.
|
|
68
|
+
* Starfish is a document-sync system; we maintain a rolling JSON document
|
|
69
|
+
* that accumulates analytics events for each identity.
|
|
70
|
+
*/
|
|
71
|
+
interface StarfishEventDocument {
|
|
72
|
+
/** All events recorded for this identity, ordered by timestamp. */
|
|
73
|
+
events: SunglassesEvent[];
|
|
74
|
+
/** ISO-8601 timestamp of the last document update. */
|
|
75
|
+
lastUpdated: string;
|
|
76
|
+
/** Schema version for future migrations. */
|
|
77
|
+
version: '1';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Creates an empty Starfish event document.
|
|
81
|
+
*/
|
|
82
|
+
declare function createEmptyDocument(): StarfishEventDocument;
|
|
83
|
+
/**
|
|
84
|
+
* Merge new events into an existing document, de-duplicating by messageId.
|
|
85
|
+
* Remote document takes precedence for metadata fields.
|
|
86
|
+
*/
|
|
87
|
+
declare function mergeEvents(remote: StarfishEventDocument, incoming: ReadonlyArray<SunglassesEvent>): StarfishEventDocument;
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the Starfish storage path for a given identity.
|
|
90
|
+
* Replaces `{identity}` in the template with the resolved identity value.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* resolveStoragePath('analytics/{identity}/events', 'user-123')
|
|
94
|
+
* // → 'analytics/user-123/events'
|
|
95
|
+
*/
|
|
96
|
+
declare function resolveStoragePath(template: string, identity: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Prune a Starfish event document according to cleanup configuration.
|
|
99
|
+
*
|
|
100
|
+
* Applied rules (in order):
|
|
101
|
+
* 1. Remove events older than `maxAgeMs` milliseconds.
|
|
102
|
+
* 2. If `maxEventsPerIdentity` is set (> 0), keep only the most recent N events.
|
|
103
|
+
*
|
|
104
|
+
* Returns a new document — does not mutate the input.
|
|
105
|
+
*/
|
|
106
|
+
declare function pruneDocument(doc: StarfishEventDocument, config: CleanupConfig): StarfishEventDocument;
|
|
107
|
+
|
|
108
|
+
export { StarfishAnalyticsAdapter, type StarfishEventDocument, createEmptyDocument, mergeEvents, pruneDocument, resolveStoragePath };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
StarfishAnalyticsAdapter: () => StarfishAnalyticsAdapter,
|
|
24
|
+
createEmptyDocument: () => createEmptyDocument,
|
|
25
|
+
mergeEvents: () => mergeEvents,
|
|
26
|
+
pruneDocument: () => pruneDocument,
|
|
27
|
+
resolveStoragePath: () => resolveStoragePath
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
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));
|
|
42
|
+
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()
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/StarfishAnalyticsAdapter.ts
|
|
72
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
73
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
74
|
+
var GEN_KEY_PREFIX = "sunglasses:starfish:gen:";
|
|
75
|
+
var StarfishAnalyticsAdapter = class {
|
|
76
|
+
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.rotatePathOnSuccess = config.rotatePathOnSuccess ?? false;
|
|
83
|
+
this.pathStorage = config.pathStorage ?? null;
|
|
84
|
+
if (this.rotatePathOnSuccess && !this.pathStorage) {
|
|
85
|
+
console.warn(
|
|
86
|
+
"[SunGlasses] StarfishAnalyticsAdapter: rotatePathOnSuccess requires pathStorage \u2014 falling back to standard mode"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async send(batch) {
|
|
91
|
+
if (batch.length === 0) return;
|
|
92
|
+
const identity = batch[0].distinctId || batch[0].anonymousId;
|
|
93
|
+
if (batch.some((e) => (e.distinctId || e.anonymousId) !== identity)) {
|
|
94
|
+
console.warn(
|
|
95
|
+
"[SunGlasses] StarfishAnalyticsAdapter: batch contains events from multiple identities \u2014 all events will be written to the document for the first identity. This is unexpected."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const baseResolved = resolveStoragePath(this.storagePath, identity);
|
|
99
|
+
if (this.rotatePathOnSuccess && this.pathStorage) {
|
|
100
|
+
return this.sendRotating(batch, identity, baseResolved);
|
|
101
|
+
}
|
|
102
|
+
return this.sendMerge(batch, baseResolved);
|
|
103
|
+
}
|
|
104
|
+
async reset() {
|
|
105
|
+
}
|
|
106
|
+
async shutdown() {
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Prune old events from the Starfish document after a successful flush.
|
|
110
|
+
* Called by SunglassesCore when `cleanupAfterFlush` is configured.
|
|
111
|
+
* Only applicable in standard (non-rotating) mode.
|
|
112
|
+
*/
|
|
113
|
+
async cleanupAfterFlush(delivered, config) {
|
|
114
|
+
if (delivered.length === 0) return;
|
|
115
|
+
if (this.rotatePathOnSuccess) return;
|
|
116
|
+
const identity = delivered[0].distinctId || delivered[0].anonymousId;
|
|
117
|
+
const path = resolveStoragePath(this.storagePath, identity);
|
|
118
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
119
|
+
try {
|
|
120
|
+
const pulled = await this.pull(path);
|
|
121
|
+
if (!pulled) return;
|
|
122
|
+
const pruned = pruneDocument(pulled.data, config);
|
|
123
|
+
const pushResponse = await this.push(path, pruned, pulled.hash);
|
|
124
|
+
if (pushResponse.status === 409) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
} catch {
|
|
129
|
+
if (attempt === this.maxRetries - 1) {
|
|
130
|
+
console.warn(
|
|
131
|
+
"[SunGlasses] StarfishAnalyticsAdapter: cleanup failed \u2014 will retry on next flush"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// ── Private: standard pull-merge-push ──────────────────────────────────────
|
|
138
|
+
async sendMerge(batch, path) {
|
|
139
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
140
|
+
try {
|
|
141
|
+
const pulled = await this.pull(path);
|
|
142
|
+
const currentDoc = pulled?.data ?? createEmptyDocument();
|
|
143
|
+
const baseHash = pulled?.hash ?? "";
|
|
144
|
+
const updatedDoc = mergeEvents(currentDoc, batch);
|
|
145
|
+
const pushResponse = await this.push(path, updatedDoc, baseHash);
|
|
146
|
+
if (pushResponse.status === 409) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!pushResponse.ok) {
|
|
150
|
+
console.warn(
|
|
151
|
+
`[SunGlasses] StarfishAnalyticsAdapter: push failed with status ${pushResponse.status} \u2014 batch discarded`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const isLastAttempt = attempt === this.maxRetries - 1;
|
|
157
|
+
if (isLastAttempt) {
|
|
158
|
+
console.warn(
|
|
159
|
+
`[SunGlasses] StarfishAnalyticsAdapter: network error after ${this.maxRetries} attempts \u2014 batch discarded`,
|
|
160
|
+
err
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
console.warn(
|
|
167
|
+
`[SunGlasses] StarfishAnalyticsAdapter: max retries (${this.maxRetries}) exceeded for path "${path}" \u2014 batch discarded`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
// ── Private: rotating path mode ────────────────────────────────────────────
|
|
171
|
+
/**
|
|
172
|
+
* Push events to a new Starfish document each time.
|
|
173
|
+
*
|
|
174
|
+
* Path format: `{baseResolved}-{generation padded to 4 digits}`
|
|
175
|
+
* e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
|
|
176
|
+
*
|
|
177
|
+
* The generation is persisted to `pathStorage` so it survives app restarts.
|
|
178
|
+
* On success: advance generation. On failure: keep current generation (retry next flush).
|
|
179
|
+
*/
|
|
180
|
+
async sendRotating(batch, identity, baseResolved) {
|
|
181
|
+
const gen = await this.loadGeneration(identity);
|
|
182
|
+
const path = `${baseResolved}-${String(gen).padStart(4, "0")}`;
|
|
183
|
+
const doc = mergeEvents(createEmptyDocument(), batch);
|
|
184
|
+
try {
|
|
185
|
+
const response = await this.push(path, doc, "");
|
|
186
|
+
if (response.ok) {
|
|
187
|
+
await this.saveGeneration(identity, gen + 1);
|
|
188
|
+
} else if (response.status === 409) {
|
|
189
|
+
await this.saveGeneration(identity, gen + 1);
|
|
190
|
+
throw new Error(
|
|
191
|
+
`[SunGlasses] StarfishAnalyticsAdapter: 409 conflict at rotating path "${path}" \u2014 generation advanced, will retry`
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
console.warn(
|
|
195
|
+
`[SunGlasses] StarfishAnalyticsAdapter: rotating push failed with status ${response.status} for path "${path}"`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.warn(
|
|
200
|
+
`[SunGlasses] StarfishAnalyticsAdapter: rotating push network error for path "${path}"`,
|
|
201
|
+
err
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async loadGeneration(identity) {
|
|
206
|
+
if (!this.pathStorage) return 0;
|
|
207
|
+
try {
|
|
208
|
+
const raw = await this.pathStorage.read(`${GEN_KEY_PREFIX}${identity}`);
|
|
209
|
+
return raw !== null ? parseInt(raw, 10) : 0;
|
|
210
|
+
} catch {
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async saveGeneration(identity, gen) {
|
|
215
|
+
if (!this.pathStorage) return;
|
|
216
|
+
try {
|
|
217
|
+
await this.pathStorage.write(`${GEN_KEY_PREFIX}${identity}`, String(gen));
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.warn("[SunGlasses] StarfishAnalyticsAdapter: failed to save generation counter", err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ── Private: HTTP helpers ──────────────────────────────────────────────────
|
|
223
|
+
async pull(path) {
|
|
224
|
+
const controller = new AbortController();
|
|
225
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
226
|
+
try {
|
|
227
|
+
const response = await fetch(`${this.serverUrl}/pull/${path}`, {
|
|
228
|
+
method: "GET",
|
|
229
|
+
headers: this.buildHeaders(),
|
|
230
|
+
signal: controller.signal
|
|
231
|
+
});
|
|
232
|
+
if (response.status === 404) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
throw new Error(`Starfish pull failed: ${response.status}`);
|
|
237
|
+
}
|
|
238
|
+
return response.json();
|
|
239
|
+
} finally {
|
|
240
|
+
clearTimeout(timeoutId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async push(path, doc, baseHash) {
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
246
|
+
try {
|
|
247
|
+
return await fetch(`${this.serverUrl}/push/${path}`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: {
|
|
250
|
+
"Content-Type": "application/json",
|
|
251
|
+
...this.buildHeaders()
|
|
252
|
+
},
|
|
253
|
+
body: JSON.stringify({
|
|
254
|
+
data: doc,
|
|
255
|
+
baseHash: baseHash || void 0
|
|
256
|
+
}),
|
|
257
|
+
signal: controller.signal
|
|
258
|
+
});
|
|
259
|
+
} finally {
|
|
260
|
+
clearTimeout(timeoutId);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
buildHeaders() {
|
|
264
|
+
const headers = {};
|
|
265
|
+
if (this.authToken) {
|
|
266
|
+
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
267
|
+
}
|
|
268
|
+
return headers;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
272
|
+
0 && (module.exports = {
|
|
273
|
+
StarfishAnalyticsAdapter,
|
|
274
|
+
createEmptyDocument,
|
|
275
|
+
mergeEvents,
|
|
276
|
+
pruneDocument,
|
|
277
|
+
resolveStoragePath
|
|
278
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// 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));
|
|
12
|
+
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()
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/StarfishAnalyticsAdapter.ts
|
|
42
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
43
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
44
|
+
var GEN_KEY_PREFIX = "sunglasses:starfish:gen:";
|
|
45
|
+
var StarfishAnalyticsAdapter = class {
|
|
46
|
+
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.rotatePathOnSuccess = config.rotatePathOnSuccess ?? false;
|
|
53
|
+
this.pathStorage = config.pathStorage ?? null;
|
|
54
|
+
if (this.rotatePathOnSuccess && !this.pathStorage) {
|
|
55
|
+
console.warn(
|
|
56
|
+
"[SunGlasses] StarfishAnalyticsAdapter: rotatePathOnSuccess requires pathStorage \u2014 falling back to standard mode"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async send(batch) {
|
|
61
|
+
if (batch.length === 0) return;
|
|
62
|
+
const identity = batch[0].distinctId || batch[0].anonymousId;
|
|
63
|
+
if (batch.some((e) => (e.distinctId || e.anonymousId) !== identity)) {
|
|
64
|
+
console.warn(
|
|
65
|
+
"[SunGlasses] StarfishAnalyticsAdapter: batch contains events from multiple identities \u2014 all events will be written to the document for the first identity. This is unexpected."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const baseResolved = resolveStoragePath(this.storagePath, identity);
|
|
69
|
+
if (this.rotatePathOnSuccess && this.pathStorage) {
|
|
70
|
+
return this.sendRotating(batch, identity, baseResolved);
|
|
71
|
+
}
|
|
72
|
+
return this.sendMerge(batch, baseResolved);
|
|
73
|
+
}
|
|
74
|
+
async reset() {
|
|
75
|
+
}
|
|
76
|
+
async shutdown() {
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Prune old events from the Starfish document after a successful flush.
|
|
80
|
+
* Called by SunglassesCore when `cleanupAfterFlush` is configured.
|
|
81
|
+
* Only applicable in standard (non-rotating) mode.
|
|
82
|
+
*/
|
|
83
|
+
async cleanupAfterFlush(delivered, config) {
|
|
84
|
+
if (delivered.length === 0) return;
|
|
85
|
+
if (this.rotatePathOnSuccess) return;
|
|
86
|
+
const identity = delivered[0].distinctId || delivered[0].anonymousId;
|
|
87
|
+
const path = resolveStoragePath(this.storagePath, identity);
|
|
88
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
89
|
+
try {
|
|
90
|
+
const pulled = await this.pull(path);
|
|
91
|
+
if (!pulled) return;
|
|
92
|
+
const pruned = pruneDocument(pulled.data, config);
|
|
93
|
+
const pushResponse = await this.push(path, pruned, pulled.hash);
|
|
94
|
+
if (pushResponse.status === 409) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
} catch {
|
|
99
|
+
if (attempt === this.maxRetries - 1) {
|
|
100
|
+
console.warn(
|
|
101
|
+
"[SunGlasses] StarfishAnalyticsAdapter: cleanup failed \u2014 will retry on next flush"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ── Private: standard pull-merge-push ──────────────────────────────────────
|
|
108
|
+
async sendMerge(batch, path) {
|
|
109
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
110
|
+
try {
|
|
111
|
+
const pulled = await this.pull(path);
|
|
112
|
+
const currentDoc = pulled?.data ?? createEmptyDocument();
|
|
113
|
+
const baseHash = pulled?.hash ?? "";
|
|
114
|
+
const updatedDoc = mergeEvents(currentDoc, batch);
|
|
115
|
+
const pushResponse = await this.push(path, updatedDoc, baseHash);
|
|
116
|
+
if (pushResponse.status === 409) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!pushResponse.ok) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`[SunGlasses] StarfishAnalyticsAdapter: push failed with status ${pushResponse.status} \u2014 batch discarded`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const isLastAttempt = attempt === this.maxRetries - 1;
|
|
127
|
+
if (isLastAttempt) {
|
|
128
|
+
console.warn(
|
|
129
|
+
`[SunGlasses] StarfishAnalyticsAdapter: network error after ${this.maxRetries} attempts \u2014 batch discarded`,
|
|
130
|
+
err
|
|
131
|
+
);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
console.warn(
|
|
137
|
+
`[SunGlasses] StarfishAnalyticsAdapter: max retries (${this.maxRetries}) exceeded for path "${path}" \u2014 batch discarded`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
// ── Private: rotating path mode ────────────────────────────────────────────
|
|
141
|
+
/**
|
|
142
|
+
* Push events to a new Starfish document each time.
|
|
143
|
+
*
|
|
144
|
+
* Path format: `{baseResolved}-{generation padded to 4 digits}`
|
|
145
|
+
* e.g. `analytics/user-1/events-0001`, `analytics/user-1/events-0002`
|
|
146
|
+
*
|
|
147
|
+
* The generation is persisted to `pathStorage` so it survives app restarts.
|
|
148
|
+
* On success: advance generation. On failure: keep current generation (retry next flush).
|
|
149
|
+
*/
|
|
150
|
+
async sendRotating(batch, identity, baseResolved) {
|
|
151
|
+
const gen = await this.loadGeneration(identity);
|
|
152
|
+
const path = `${baseResolved}-${String(gen).padStart(4, "0")}`;
|
|
153
|
+
const doc = mergeEvents(createEmptyDocument(), batch);
|
|
154
|
+
try {
|
|
155
|
+
const response = await this.push(path, doc, "");
|
|
156
|
+
if (response.ok) {
|
|
157
|
+
await this.saveGeneration(identity, gen + 1);
|
|
158
|
+
} else if (response.status === 409) {
|
|
159
|
+
await this.saveGeneration(identity, gen + 1);
|
|
160
|
+
throw new Error(
|
|
161
|
+
`[SunGlasses] StarfishAnalyticsAdapter: 409 conflict at rotating path "${path}" \u2014 generation advanced, will retry`
|
|
162
|
+
);
|
|
163
|
+
} else {
|
|
164
|
+
console.warn(
|
|
165
|
+
`[SunGlasses] StarfishAnalyticsAdapter: rotating push failed with status ${response.status} for path "${path}"`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.warn(
|
|
170
|
+
`[SunGlasses] StarfishAnalyticsAdapter: rotating push network error for path "${path}"`,
|
|
171
|
+
err
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async loadGeneration(identity) {
|
|
176
|
+
if (!this.pathStorage) return 0;
|
|
177
|
+
try {
|
|
178
|
+
const raw = await this.pathStorage.read(`${GEN_KEY_PREFIX}${identity}`);
|
|
179
|
+
return raw !== null ? parseInt(raw, 10) : 0;
|
|
180
|
+
} catch {
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async saveGeneration(identity, gen) {
|
|
185
|
+
if (!this.pathStorage) return;
|
|
186
|
+
try {
|
|
187
|
+
await this.pathStorage.write(`${GEN_KEY_PREFIX}${identity}`, String(gen));
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.warn("[SunGlasses] StarfishAnalyticsAdapter: failed to save generation counter", err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ── Private: HTTP helpers ──────────────────────────────────────────────────
|
|
193
|
+
async pull(path) {
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
196
|
+
try {
|
|
197
|
+
const response = await fetch(`${this.serverUrl}/pull/${path}`, {
|
|
198
|
+
method: "GET",
|
|
199
|
+
headers: this.buildHeaders(),
|
|
200
|
+
signal: controller.signal
|
|
201
|
+
});
|
|
202
|
+
if (response.status === 404) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`Starfish pull failed: ${response.status}`);
|
|
207
|
+
}
|
|
208
|
+
return response.json();
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timeoutId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async push(path, doc, baseHash) {
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
216
|
+
try {
|
|
217
|
+
return await fetch(`${this.serverUrl}/push/${path}`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {
|
|
220
|
+
"Content-Type": "application/json",
|
|
221
|
+
...this.buildHeaders()
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
data: doc,
|
|
225
|
+
baseHash: baseHash || void 0
|
|
226
|
+
}),
|
|
227
|
+
signal: controller.signal
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
clearTimeout(timeoutId);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
buildHeaders() {
|
|
234
|
+
const headers = {};
|
|
235
|
+
if (this.authToken) {
|
|
236
|
+
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
237
|
+
}
|
|
238
|
+
return headers;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
export {
|
|
242
|
+
StarfishAnalyticsAdapter,
|
|
243
|
+
createEmptyDocument,
|
|
244
|
+
mergeEvents,
|
|
245
|
+
pruneDocument,
|
|
246
|
+
resolveStoragePath
|
|
247
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@drakkar.software/sunglasses-adapter-starfish",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Starfish document-sync adapter for SunGlasses (Drakkar-Software/Starfish)",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@drakkar.software/sunglasses-core": "0.2.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"tsup": "^8.3.5",
|
|
23
|
+
"typescript": "^5.7.2",
|
|
24
|
+
"vitest": "^2.1.8",
|
|
25
|
+
"@drakkar.software/sunglasses-tsconfig": "0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
29
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"lint": "eslint src/",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"clean": "rm -rf dist .tsbuildinfo"
|
|
34
|
+
}
|
|
35
|
+
}
|