@volter/twin-segment 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/README.md +154 -0
- package/client/segment-mirror.css +45 -0
- package/client/segment-mirror.tsx +154 -0
- package/dist/client/segment-mirror.bundle.js +342 -0
- package/dist/client/segment-mirror.css +45 -0
- package/dist/client/segment-mirror.d.ts +34 -0
- package/dist/client/segment-mirror.js +80 -0
- package/dist/client/segment-mirror.tsx +154 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +34 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +53 -0
- package/dist/src/segment-budget.d.ts +41 -0
- package/dist/src/segment-budget.js +112 -0
- package/dist/src/segment-capabilities.d.ts +12 -0
- package/dist/src/segment-capabilities.gen.d.ts +3 -0
- package/dist/src/segment-capabilities.gen.js +22 -0
- package/dist/src/segment-capabilities.js +907 -0
- package/dist/src/segment-conformance.d.ts +8 -0
- package/dist/src/segment-conformance.js +106 -0
- package/dist/src/segment-connector.d.ts +76 -0
- package/dist/src/segment-connector.js +226 -0
- package/dist/src/segment-mirror-ui.d.ts +42 -0
- package/dist/src/segment-mirror-ui.js +143 -0
- package/dist/src/segment-server.d.ts +25 -0
- package/dist/src/segment-server.js +90 -0
- package/dist/src/segment-surface.gen.d.ts +48 -0
- package/dist/src/segment-surface.gen.js +267 -0
- package/dist/src/segment-twin.d.ts +98 -0
- package/dist/src/segment-twin.js +543 -0
- package/package.json +59 -0
- package/src/cli.ts +30 -0
- package/src/index.ts +87 -0
- package/src/segment-budget.ts +138 -0
- package/src/segment-capabilities.gen.ts +25 -0
- package/src/segment-capabilities.ts +988 -0
- package/src/segment-conformance.ts +123 -0
- package/src/segment-connector.ts +233 -0
- package/src/segment-journey.uitest.ts +116 -0
- package/src/segment-mirror-ui.ts +157 -0
- package/src/segment-server.ts +95 -0
- package/src/segment-surface.gen.ts +277 -0
- package/src/segment-twin.ts +664 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// Segment twin — SEMANTICS registry + THE request loop. The generated surface
|
|
2
|
+
// (segment-surface.gen.ts) is data + pure helpers only; every judgment lives here, grounded in
|
|
3
|
+
// the pinned SDK tarball and the vendor's own docs source (both cited per claim below).
|
|
4
|
+
// Unknown route → vendor-shaped 404; a ratified but unmodeled operation → LOUD gap, never a
|
|
5
|
+
// fake success. Flip a capability in segment-capabilities.ts only with a real verify().
|
|
6
|
+
//
|
|
7
|
+
// ── WHAT THE UNMODIFIED SDK ACTUALLY SENDS ───────────────────────────────────────────────────
|
|
8
|
+
// @segment/analytics-node@2.3.0 has SIX typed call methods (track/identify/page/screen/group/
|
|
9
|
+
// alias, src/app/analytics-node.ts) and exactly ONE wire route. Its Publisher fixes the URL in
|
|
10
|
+
// its constructor — `tryCreateFormattedUrl(host ?? 'https://api.segment.io', path ?? '/v1/batch')`
|
|
11
|
+
// (src/plugins/segmentio/publisher.ts:73-76) — and every send POSTs that same URL with
|
|
12
|
+
// JSON.stringify({ batch: events, writeKey: this._writeKey, sentAt: new Date() }) (:243-247)
|
|
13
|
+
// so a batch is a LIST OF INDEPENDENT MESSAGES, each carrying its OWN `type`. Dispatch is
|
|
14
|
+
// therefore PER MESSAGE. Discriminating on `batch[0]` would silently mis-route or lose every
|
|
15
|
+
// message after the first, which is the defect var/line/mixpanel/REVIEW.md F2 records for the
|
|
16
|
+
// sibling analytics pack — and it is not a corner case here either: `flushAt` defaults to 15
|
|
17
|
+
// (src/app/analytics-node.ts), so an ordinary process flushes a mixed identify/track/page array.
|
|
18
|
+
//
|
|
19
|
+
// ── AUTH: THREE DOCUMENTED SCHEMES, AND `Bearer <writeKey>` IS NONE OF THEM ───────────────────
|
|
20
|
+
// segment-docs src/connections/sources/catalog/libraries/server/http-api/index.md '### Authentication':
|
|
21
|
+
// 1. writeKey IN THE BODY, no auth header at all ("For this auth type, you do not need to set
|
|
22
|
+
// any authentication header"). This is what analytics-node@2 and analytics-python send.
|
|
23
|
+
// 2. HTTP Basic with the write key as the USERNAME and an EMPTY password — "taking a Segment
|
|
24
|
+
// source Write Key, 'abc123', as the username, adding a colon, and then the password field
|
|
25
|
+
// is left empty. After base64 encoding 'abc123:' becomes 'YWJjMTIzOg=='". This is what
|
|
26
|
+
// analytics-node v1 sends (`auth: { username: this.writeKey }`).
|
|
27
|
+
// 3. OAuth `Authorization: Bearer <access token>` PLUS the write key still in the payload.
|
|
28
|
+
// A twin that DEMANDED an Authorization header could not be reached by the pinned SDK at all.
|
|
29
|
+
// (Ruled: var/line/segment/RULINGS.json, denominator:auth-is-three-documented-schemes-not-one.)
|
|
30
|
+
//
|
|
31
|
+
// ── WHAT SEGMENT ANSWERS ─────────────────────────────────────────────────────────────────────
|
|
32
|
+
// "Segment returns a 200 response for all API requests except errors caused by large payloads
|
|
33
|
+
// and JSON errors (which return 400 responses.)" (same page, '## Errors'). Events can therefore
|
|
34
|
+
// be ACCEPTED-AND-DROPPED: a payload with neither userId nor anonymousId gets a `no_user_anon_id`
|
|
35
|
+
// error, a Track with no `event` field is rejected, and a batch over 2500 events gets "a 200
|
|
36
|
+
// response but rejects the event". This twin reproduces that exactly — 200, nothing folded into
|
|
37
|
+
// `event`, and the drop recorded in a local `dropped` projection so the outcome is inspectable
|
|
38
|
+
// (twin state, not vendor surface). The 400 body shape is `{code, message}`, grounded in the
|
|
39
|
+
// first-party parser analytics-python uses: `raise APIError(res.status_code, payload["code"],
|
|
40
|
+
// payload["message"])` (segment/analytics/request.py).
|
|
41
|
+
import { applyTwinWriteAtomic, projectResources, worldNow } from '@volter/world-core';
|
|
42
|
+
import { OPS, matchOp } from "./segment-surface.gen.js";
|
|
43
|
+
const SERVICE = 'segment';
|
|
44
|
+
const rows = (root) => projectResources(SERVICE, root);
|
|
45
|
+
/** Every accepted message, oldest-first by ingestion ordinal. One row per Segment `messageId`. */
|
|
46
|
+
export function events(root) {
|
|
47
|
+
return rows(root).filter((r) => r.type === 'event').sort((a, b) => Number(a.seq ?? 0) - Number(b.seq ?? 0));
|
|
48
|
+
}
|
|
49
|
+
/** Users as identify (and alias) have folded them: merged traits + the ids they answer to. */
|
|
50
|
+
export function identities(root) {
|
|
51
|
+
return rows(root).filter((r) => r.type === 'identity');
|
|
52
|
+
}
|
|
53
|
+
/** Groups as `group` calls have folded them: merged group traits. */
|
|
54
|
+
export function groups(root) {
|
|
55
|
+
return rows(root).filter((r) => r.type === 'group');
|
|
56
|
+
}
|
|
57
|
+
/** Messages the vendor ACCEPTS (200) and then drops. Twin-local observability, not vendor surface. */
|
|
58
|
+
export function dropped(root) {
|
|
59
|
+
return rows(root).filter((r) => r.type === 'dropped').sort((a, b) => Number(a.seq ?? 0) - Number(b.seq ?? 0));
|
|
60
|
+
}
|
|
61
|
+
// ── vendor error envelope ───────────────────────────────────────────────────────────────────
|
|
62
|
+
/** Segment's error body. The SHAPE is grounded — analytics-python reads `payload["code"]` and
|
|
63
|
+
* `payload["message"]` off any non-200 (segment/analytics/request.py) — but the vendor
|
|
64
|
+
* publishes no vocabulary of `code` VALUES. The single documented literal is
|
|
65
|
+
* `no_user_anon_id` (http-api/index.md '## Errors'); every other code below is descriptive and
|
|
66
|
+
* is NOT claimed to be the vendor's own string. The gap is filed as
|
|
67
|
+
* segment.api.errors.code_vocabulary and no capability asserts a code the vendor never printed. */
|
|
68
|
+
export const segmentError = (status, code, message) => ({
|
|
69
|
+
status,
|
|
70
|
+
body: { code, message },
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
});
|
|
73
|
+
// ── limits, as the vendor publishes them ────────────────────────────────────────────────────
|
|
74
|
+
// '## Max request size': "There is a maximum of 32KB per normal API request. The batch API
|
|
75
|
+
// endpoint accepts a maximum of 500KB per request, with a limit of 32KB per event in the batch...
|
|
76
|
+
// Segment's API responds with 400 Bad Request if these limits are exceeded."
|
|
77
|
+
// '## Errors': "Each batch request can only have up to 2500 events".
|
|
78
|
+
export const SEGMENT_MAX_REQUEST_BYTES = 32 * 1024;
|
|
79
|
+
export const SEGMENT_MAX_BATCH_BYTES = 500 * 1024;
|
|
80
|
+
export const SEGMENT_MAX_BATCH_EVENTS = 2500;
|
|
81
|
+
const byteLength = (s) => new TextEncoder().encode(s).length;
|
|
82
|
+
/** Resolve the write key from whichever of the three documented schemes the caller used.
|
|
83
|
+
* The BODY wins when it carries a writeKey, because the docs' OAuth example keeps the writeKey
|
|
84
|
+
* in the payload while the header carries a different credential entirely; Basic is consulted
|
|
85
|
+
* only when the body has none, which is exactly the analytics-node v1 shape. */
|
|
86
|
+
export function resolveWriteKey(authorization, body) {
|
|
87
|
+
const header = (authorization ?? '').trim();
|
|
88
|
+
const bodyKey = body && typeof body === 'object' && typeof body.writeKey === 'string'
|
|
89
|
+
? body.writeKey
|
|
90
|
+
: null;
|
|
91
|
+
if (/^basic /i.test(header)) {
|
|
92
|
+
// "taking a Segment source Write Key, 'abc123', as the username, adding a colon, and then
|
|
93
|
+
// the password field is left empty. After base64 encoding 'abc123:' becomes 'YWJjMTIzOg=='".
|
|
94
|
+
// Decoded as UTF-8 BYTES, not latin-1: `atob` alone yields one JS char per byte and mangles
|
|
95
|
+
// any non-ASCII credential (the var/line/mixpanel/REVIEW.md F1 class).
|
|
96
|
+
let decoded;
|
|
97
|
+
try {
|
|
98
|
+
const rawB64 = header.slice(6).trim();
|
|
99
|
+
decoded = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(rawB64), (c) => c.charCodeAt(0)));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
decoded = undefined;
|
|
103
|
+
}
|
|
104
|
+
const username = decoded?.split(':')[0];
|
|
105
|
+
if (username)
|
|
106
|
+
return { writeKey: bodyKey ?? username, scheme: bodyKey ? 'body' : 'basic', bearer: null };
|
|
107
|
+
return { writeKey: bodyKey, scheme: bodyKey ? 'body' : 'none', bearer: null };
|
|
108
|
+
}
|
|
109
|
+
if (/^bearer /i.test(header)) {
|
|
110
|
+
// OAuth: the access token is the header, the write key stays in the payload.
|
|
111
|
+
return { writeKey: bodyKey, scheme: 'oauth', bearer: header.slice(7).trim() || null };
|
|
112
|
+
}
|
|
113
|
+
return { writeKey: bodyKey, scheme: bodyKey ? 'body' : 'none', bearer: null };
|
|
114
|
+
}
|
|
115
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : undefined);
|
|
116
|
+
const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
117
|
+
/** The five message types this twin models, plus the one it ratifies and refuses BY NAME.
|
|
118
|
+
* The union is the SDK's own: `type SegmentEventType = 'track' | 'page' | 'identify' | 'alias'
|
|
119
|
+
* | 'screen'` (src/app/types/segment-event.ts) widened with 'group', which the SDK's
|
|
120
|
+
* `Analytics.group()` produces through CoreEventFactory. */
|
|
121
|
+
export const MODELED_TYPES = ['track', 'identify', 'page', 'group', 'alias'];
|
|
122
|
+
/** Ratified in SURFACE.json, deliberately not modeled: it earns the loud gap by name. */
|
|
123
|
+
export const UNMODELED_TYPES = ['screen'];
|
|
124
|
+
// ── validation, only where the vendor states the requirement in prose ───────────────────────
|
|
125
|
+
/** "The HTTP API requires that each payload has a userId and/or anonymousId. If you send events
|
|
126
|
+
* without either the userId or anonymousId, Segment's tracking API responds with an
|
|
127
|
+
* no_user_anon_id error." (http-api/index.md '## Errors') */
|
|
128
|
+
function dropReasonFor(msg, type) {
|
|
129
|
+
if (!str(msg.userId) && !str(msg.anonymousId))
|
|
130
|
+
return 'no_user_anon_id';
|
|
131
|
+
// "All Track events sent to Segment must have an `event` field."
|
|
132
|
+
if (type === 'track' && !str(msg.event))
|
|
133
|
+
return 'track_missing_event';
|
|
134
|
+
// '## Group' field table declares groupId; a group call with nothing to group is not a group.
|
|
135
|
+
if (type === 'group' && !str(msg.groupId))
|
|
136
|
+
return 'group_missing_group_id';
|
|
137
|
+
// '## Alias' field table declares previousId; the SDK's AliasParams requires it too.
|
|
138
|
+
if (type === 'alias' && !str(msg.previousId))
|
|
139
|
+
return 'alias_missing_previous_id';
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
// ── ids and ordinals ────────────────────────────────────────────────────────────────────────
|
|
143
|
+
/** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist. */
|
|
144
|
+
function stableHex(seed, n) {
|
|
145
|
+
let out = '';
|
|
146
|
+
for (let round = 0; out.length < n; round += 1) {
|
|
147
|
+
let h = 0x811c9dc5;
|
|
148
|
+
const s = `${round}:${seed}`;
|
|
149
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
150
|
+
h ^= s.charCodeAt(i);
|
|
151
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
152
|
+
}
|
|
153
|
+
out += h.toString(16).padStart(8, '0');
|
|
154
|
+
}
|
|
155
|
+
return out.slice(0, n);
|
|
156
|
+
}
|
|
157
|
+
/** The vendor's own dedupe key when the caller supplied one, a DERIVED uuid otherwise. Segment
|
|
158
|
+
* mints this server-side too — "Segment deduplicates events using the messageId field, which is
|
|
159
|
+
* automatically added to all payloads coming into Segment" — so a hand-rolled HTTP call with no
|
|
160
|
+
* messageId still gets one.
|
|
161
|
+
*
|
|
162
|
+
* It was `crypto.randomUUID()`, which made the stored feed differ byte-for-byte between two
|
|
163
|
+
* identical worlds — invisible while this twin had no read surface, and a real R9 break the
|
|
164
|
+
* moment the store doors opened. Still NEVER a module counter or a row count (ADDING_A_TWIN §5);
|
|
165
|
+
* it is a HASH over the world instant, the flush's ingestion ordinal and the message's own
|
|
166
|
+
* position in it, so it is unique per message, uniform over the id space, and identical across
|
|
167
|
+
* identical worlds. */
|
|
168
|
+
const messageIdOf = (msg, at, ordinal) => {
|
|
169
|
+
const supplied = str(msg.messageId);
|
|
170
|
+
if (supplied)
|
|
171
|
+
return supplied;
|
|
172
|
+
const hex = stableHex(`message:${at}:${ordinal}`, 32);
|
|
173
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
174
|
+
};
|
|
175
|
+
/** A strictly-increasing per-type ordinal read out of CURRENT state. It orders the feed AND makes
|
|
176
|
+
* each write's content unique, so the kernel's content+millisecond dedupe can never mistake a
|
|
177
|
+
* genuine repeat for a replay (ADDING_A_TWIN §5) — Segment BILLS per event, so two identical
|
|
178
|
+
* events in one millisecond must both land. */
|
|
179
|
+
const nextSeq = (resources, type) => resources.reduce((n, r) => (r.type === type ? Math.max(n, Number(r.seq ?? 0)) : n), 0) + 1;
|
|
180
|
+
function eventFieldsFor(msg, type, env, endpoint, messageId, seq, receivedAt) {
|
|
181
|
+
// MERGE: "The same as Context for other calls, but it will be merged with any context inside
|
|
182
|
+
// each of the items in the batch" — and likewise for `integrations` (http-api/index.md, the
|
|
183
|
+
// batch field table). Message-level keys win over batch-level ones.
|
|
184
|
+
return {
|
|
185
|
+
// `type` is META-reserved by the kernel — see the docblock above.
|
|
186
|
+
messageType: type,
|
|
187
|
+
messageId,
|
|
188
|
+
endpoint,
|
|
189
|
+
userId: str(msg.userId) ?? null,
|
|
190
|
+
anonymousId: str(msg.anonymousId) ?? null,
|
|
191
|
+
writeKey: env.writeKey,
|
|
192
|
+
authScheme: env.scheme,
|
|
193
|
+
event: str(msg.event) ?? null,
|
|
194
|
+
name: str(msg.name) ?? null,
|
|
195
|
+
category: str(msg.category) ?? null,
|
|
196
|
+
groupId: str(msg.groupId) ?? null,
|
|
197
|
+
previousId: str(msg.previousId) ?? null,
|
|
198
|
+
properties: obj(msg.properties) ?? null,
|
|
199
|
+
traits: obj(msg.traits) ?? null,
|
|
200
|
+
context: { ...(env.context ?? {}), ...(obj(msg.context) ?? {}) },
|
|
201
|
+
integrations: { ...(env.integrations ?? {}), ...(obj(msg.integrations) ?? {}) },
|
|
202
|
+
// Four timestamps, per src/connections/spec/common.md '## Timestamps'. `timestamp` is the
|
|
203
|
+
// caller's (or the SDK's `new Date()`); `sentAt` rides on the envelope; `receivedAt` is
|
|
204
|
+
// "added to incoming messages as soon as they hit the API", i.e. server-set, here.
|
|
205
|
+
timestamp: typeof msg.timestamp === 'string' ? msg.timestamp : null,
|
|
206
|
+
sentAt: env.sentAt,
|
|
207
|
+
receivedAt,
|
|
208
|
+
seq,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** Fold one validated message into the in-flight create list, merging over BOTH committed state
|
|
212
|
+
* and anything an earlier message of the SAME request already produced (two identify calls for
|
|
213
|
+
* one user in one flush must accumulate, not clobber). */
|
|
214
|
+
function foldOne(msg, type, env, endpoint, ctx) {
|
|
215
|
+
// Always present: the caller mints it (or takes the caller's) before folding.
|
|
216
|
+
const messageId = str(msg.messageId);
|
|
217
|
+
const userId = str(msg.userId) ?? null;
|
|
218
|
+
const anonymousId = str(msg.anonymousId) ?? null;
|
|
219
|
+
const subject = userId ?? anonymousId;
|
|
220
|
+
const out = [];
|
|
221
|
+
ctx.seq.event += 1;
|
|
222
|
+
const eventFields = eventFieldsFor(msg, type, env, endpoint, messageId, ctx.seq.event, ctx.receivedAt);
|
|
223
|
+
out.push({ type: 'event', id: messageId, fields: eventFields });
|
|
224
|
+
/** Current fields for a subject: this request's pending version first, then committed state. */
|
|
225
|
+
const currentOf = (kind, id) => ctx.pending.get(`${kind}:${id}`)?.fields ??
|
|
226
|
+
ctx.resources.find((r) => r.type === kind && r.id === id);
|
|
227
|
+
if (type === 'identify') {
|
|
228
|
+
// '## Identify': "Segment recommends calling Identify a single time when the user's account
|
|
229
|
+
// is first created, and only identifying again later when their traits change." Later traits
|
|
230
|
+
// MERGE over earlier ones rather than replacing the profile.
|
|
231
|
+
const current = currentOf('identity', subject);
|
|
232
|
+
out.push({
|
|
233
|
+
type: 'identity',
|
|
234
|
+
id: subject,
|
|
235
|
+
fields: {
|
|
236
|
+
userId,
|
|
237
|
+
anonymousId: anonymousId ?? current?.anonymousId ?? null,
|
|
238
|
+
traits: { ...(obj(current?.traits) ?? {}), ...(obj(msg.traits) ?? {}) },
|
|
239
|
+
previousIds: current?.previousIds ?? [],
|
|
240
|
+
// A per-write ordinal: re-setting a trait to a value it previously held inside one
|
|
241
|
+
// millisecond would otherwise hash-collide with the earlier action and be dropped as
|
|
242
|
+
// `replayed` (ADDING_A_TWIN §5, the upstash class).
|
|
243
|
+
rev: Number(current?.rev ?? 0) + 1,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else if (type === 'group') {
|
|
248
|
+
const groupId = str(msg.groupId);
|
|
249
|
+
const current = currentOf('group', groupId);
|
|
250
|
+
out.push({
|
|
251
|
+
type: 'group',
|
|
252
|
+
id: groupId,
|
|
253
|
+
fields: {
|
|
254
|
+
groupId,
|
|
255
|
+
traits: { ...(obj(current?.traits) ?? {}), ...(obj(msg.traits) ?? {}) },
|
|
256
|
+
members: [...new Set([...(current?.members ?? []), subject])],
|
|
257
|
+
rev: Number(current?.rev ?? 0) + 1,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
else if (type === 'alias') {
|
|
262
|
+
// '## Alias': "Alias is how you associate one identity with another" — previousId (a userId
|
|
263
|
+
// OR an anonymousId) becomes reachable as the new userId.
|
|
264
|
+
const previousId = str(msg.previousId);
|
|
265
|
+
const target = userId ?? subject;
|
|
266
|
+
const current = currentOf('identity', target);
|
|
267
|
+
const prior = currentOf('identity', previousId);
|
|
268
|
+
out.push({
|
|
269
|
+
type: 'identity',
|
|
270
|
+
id: target,
|
|
271
|
+
fields: {
|
|
272
|
+
userId: target,
|
|
273
|
+
anonymousId: current?.anonymousId ?? null,
|
|
274
|
+
// Aliasing carries the prior identity's traits forward for keys the target lacks; the
|
|
275
|
+
// target's own values win.
|
|
276
|
+
traits: { ...(obj(prior?.traits) ?? {}), ...(obj(current?.traits) ?? {}) },
|
|
277
|
+
previousIds: [...new Set([...(current?.previousIds ?? []), previousId])],
|
|
278
|
+
rev: Number(current?.rev ?? 0) + 1,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
for (const c of out)
|
|
283
|
+
ctx.pending.set(`${c.type}:${c.id}`, c);
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
// ── the shared ingest path ──────────────────────────────────────────────────────────────────
|
|
287
|
+
/** THE per-message dispatcher. Every route funnels through it, so `/v1/batch`, `/v1/track` and
|
|
288
|
+
* their siblings can never disagree about what a message means.
|
|
289
|
+
*
|
|
290
|
+
* Two refusal modes, and the difference is deliberate:
|
|
291
|
+
* • a DOCUMENTED rejection (no identifier, Track without a name, an over-cap or oversize
|
|
292
|
+
* batch member) is ACCEPTED-AND-DROPPED — 200, nothing folded into `event` — because that is
|
|
293
|
+
* what Segment does: "Segment returns a 200 response for all API requests except errors
|
|
294
|
+
* caused by large payloads and JSON errors", and its list of silently-rejected events names
|
|
295
|
+
* exactly these cases;
|
|
296
|
+
* • a message whose `type` this twin has RATIFIED but not MODELED (today: `screen`) fails
|
|
297
|
+
* LOUDLY by name and the WHOLE payload is refused with nothing stored. That is a deliberate
|
|
298
|
+
* deviation from the vendor's 200 (ruled:
|
|
299
|
+
* denominator:200-for-nearly-everything-so-the-twin-gap-is-a-deliberate-deviation): a silent
|
|
300
|
+
* 200 storing nothing is indistinguishable from success, which is the one thing a twin may
|
|
301
|
+
* never do. Validation of every message therefore precedes ANY fold. */
|
|
302
|
+
export async function ingest(env, endpoint, root, at) {
|
|
303
|
+
const plan = [];
|
|
304
|
+
const drops = [];
|
|
305
|
+
for (const [index, entry] of env.messages.entries()) {
|
|
306
|
+
const msg = obj(entry);
|
|
307
|
+
if (!msg) {
|
|
308
|
+
return segmentError(400, 'invalid_message', `batch[${index}] is not an object — each batch item must be a Segment message`);
|
|
309
|
+
}
|
|
310
|
+
// "Each batch request can only have up to 2500 events... Segment returns a 200 response but
|
|
311
|
+
// rejects the event when the number of batched events exceeds the limit."
|
|
312
|
+
if (env.batched && index >= SEGMENT_MAX_BATCH_EVENTS) {
|
|
313
|
+
drops.push({ msg, type: str(msg.type) ?? '', reason: 'batch_exceeds_2500_events' });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
// "a limit of 32KB per event in the batch".
|
|
317
|
+
if (env.batched && byteLength(JSON.stringify(msg)) > SEGMENT_MAX_REQUEST_BYTES) {
|
|
318
|
+
drops.push({ msg, type: str(msg.type) ?? '', reason: 'event_exceeds_32kb' });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const type = str(msg.type);
|
|
322
|
+
if (!type) {
|
|
323
|
+
// The batch field table: "Each call must have an `type` property with a valid method name."
|
|
324
|
+
drops.push({ msg, type: '', reason: 'unknown_message_type' });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (UNMODELED_TYPES.includes(type))
|
|
328
|
+
return gapForType(type, endpoint);
|
|
329
|
+
if (!MODELED_TYPES.includes(type)) {
|
|
330
|
+
drops.push({ msg, type, reason: 'unknown_message_type' });
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const reason = dropReasonFor(msg, type);
|
|
334
|
+
if (reason)
|
|
335
|
+
drops.push({ msg, type, reason });
|
|
336
|
+
else
|
|
337
|
+
plan.push({ msg, type: type });
|
|
338
|
+
}
|
|
339
|
+
if (plan.length) {
|
|
340
|
+
// The WORLD instant, never wall time: `receivedAt` is STORED on every event row and served
|
|
341
|
+
// through the store door, so a `new Date()` here made the feed differ across worlds (R9).
|
|
342
|
+
const receivedAt = at;
|
|
343
|
+
await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
344
|
+
// "Segment deduplicates events using the messageId field" (http-api/index.md '## Errors').
|
|
345
|
+
// Checked under the same cross-process action lock the write takes, so two concurrent
|
|
346
|
+
// flushes of the same messageId cannot both pass it — and within one request too, so a
|
|
347
|
+
// batch that repeats a messageId folds it once.
|
|
348
|
+
const seen = new Set(resources.filter((r) => r.type === 'event').map((r) => r.id));
|
|
349
|
+
const ctx = {
|
|
350
|
+
resources,
|
|
351
|
+
pending: new Map(),
|
|
352
|
+
seq: { event: nextSeq(resources, 'event') - 1 },
|
|
353
|
+
receivedAt,
|
|
354
|
+
};
|
|
355
|
+
const creates = [];
|
|
356
|
+
let deduped = 0;
|
|
357
|
+
for (const { msg, type } of plan) {
|
|
358
|
+
// Minted INSIDE the lock, from the ordinal `foldOne` is about to assign this message:
|
|
359
|
+
// the same state snapshot the write lands on, so the id is unique per message with no
|
|
360
|
+
// entropy and no wall clock (R9).
|
|
361
|
+
const messageId = messageIdOf(msg, at, ctx.seq.event + 1);
|
|
362
|
+
if (seen.has(messageId)) {
|
|
363
|
+
deduped += 1;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
seen.add(messageId);
|
|
367
|
+
creates.push(...foldOne({ ...msg, messageId }, type, env, endpoint, ctx));
|
|
368
|
+
}
|
|
369
|
+
// Later creates for the same subject supersede earlier ones (the kernel applies them in
|
|
370
|
+
// order and merges, but collapsing here keeps the action's projection minimal and makes
|
|
371
|
+
// the batch's net effect on each subject legible in one place).
|
|
372
|
+
const collapsed = [...new Map(creates.map((c) => [`${c.type}:${c.id}`, c])).values()];
|
|
373
|
+
const eventCreates = creates.filter((c) => c.type === 'event');
|
|
374
|
+
if (!eventCreates.length)
|
|
375
|
+
return { kind: 'skip', value: { stored: 0, deduped } };
|
|
376
|
+
const primary = eventCreates[0];
|
|
377
|
+
return {
|
|
378
|
+
kind: 'write',
|
|
379
|
+
value: { stored: eventCreates.length, deduped },
|
|
380
|
+
write: {
|
|
381
|
+
// ONE ACTION PER FLUSH — see the "folding a request" docblock. `operation` names the
|
|
382
|
+
// route rather than a single message type, because a batch is heterogeneous.
|
|
383
|
+
operation: `ingest.${endpoint.replace(/^\/+/, '').replace(/\//g, '.')}`,
|
|
384
|
+
subjectType: 'event',
|
|
385
|
+
subjectId: primary.id,
|
|
386
|
+
// The WORLD instant, never the kernel's wall-clock default: it lands on every row as
|
|
387
|
+
// `updatedAt`, which the store doors serve (R9).
|
|
388
|
+
occurredAt: at,
|
|
389
|
+
fields: primary.fields,
|
|
390
|
+
projection: { creates: collapsed },
|
|
391
|
+
actor: { kind: 'agent' },
|
|
392
|
+
// Every accepted flush is a BILLED occurrence, not an idempotent state write: two
|
|
393
|
+
// byte-identical flushes in one millisecond must ledger as two actions. The messageIds
|
|
394
|
+
// are what make them distinguishable, and a truly identical replay is already caught
|
|
395
|
+
// by the messageId dedupe above.
|
|
396
|
+
uniqueness: `${primary.id}:${eventCreates.length}`,
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}, root);
|
|
400
|
+
}
|
|
401
|
+
if (drops.length) {
|
|
402
|
+
// Dropped messages never enter the event feed, so they have no ingestion ordinal of their
|
|
403
|
+
// own — they mint from a DISJOINT ordinal space (negative) so a drop's derived id can never
|
|
404
|
+
// collide with an accepted message's.
|
|
405
|
+
const dropCreates = drops.map((d, i) => ({ msg: d.msg, type: d.type, reason: d.reason, messageId: messageIdOf(d.msg, at, -(i + 1)) }));
|
|
406
|
+
await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
407
|
+
let seq = nextSeq(resources, 'dropped') - 1;
|
|
408
|
+
const creates = dropCreates.map((d) => {
|
|
409
|
+
seq += 1;
|
|
410
|
+
return {
|
|
411
|
+
type: 'dropped',
|
|
412
|
+
id: `drop_${d.messageId}`,
|
|
413
|
+
fields: { messageType: d.type, messageId: d.messageId, endpoint, reason: d.reason, seq },
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
const primary = creates[0];
|
|
417
|
+
return {
|
|
418
|
+
kind: 'write',
|
|
419
|
+
value: null,
|
|
420
|
+
write: {
|
|
421
|
+
operation: 'drop.messages',
|
|
422
|
+
subjectType: 'dropped',
|
|
423
|
+
subjectId: primary.id,
|
|
424
|
+
occurredAt: at, // the WORLD instant (R9) — see the accepted-flush write above
|
|
425
|
+
fields: primary.fields,
|
|
426
|
+
projection: { creates },
|
|
427
|
+
actor: { kind: 'agent' },
|
|
428
|
+
uniqueness: `${primary.id}:${creates.length}`,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}, root);
|
|
432
|
+
}
|
|
433
|
+
// 200 is what the vendor answers for everything it did not refuse outright. The BODY is not
|
|
434
|
+
// documented on any first-party page and no pinned client reads it (analytics-node@2 checks
|
|
435
|
+
// only `response.status >= 200 && < 300`; analytics-python returns the response unparsed on
|
|
436
|
+
// 200) — so `{success:true}` is this twin's choice, filed as segment.api.batch.success_body
|
|
437
|
+
// and asserted by no capability.
|
|
438
|
+
return { status: 200, body: { success: true }, headers: { 'content-type': 'application/json' } };
|
|
439
|
+
}
|
|
440
|
+
/** The loud gap for a ratified-but-unmodeled MESSAGE TYPE inside an otherwise-modeled route. */
|
|
441
|
+
function gapForType(type, endpoint) {
|
|
442
|
+
const op = OPS.find((o) => o.id === type);
|
|
443
|
+
return segmentError(404, 'twin_gap', `[twin gap] message type '${type}' (arriving at ${endpoint}${op ? `; ratified as ${op.method} ${op.path}` : ''}) is in the segment twin's ratified surface but is not yet modeled — the whole payload was refused and nothing was stored. DEVIATION: the real Segment answers 200 here and silently drops the message; that is indistinguishable from acceptance, so this twin refuses instead`);
|
|
444
|
+
}
|
|
445
|
+
/** The loud gap for a ratified-but-unmodeled OPERATION (a whole route). The reader of this 404 is
|
|
446
|
+
* the one person who most needs to know it is a DEVIATION, so the body says so: the real vendor
|
|
447
|
+
* would answer 200 here (and then drop the event), which is precisely why the twin may not. */
|
|
448
|
+
function gap(op) {
|
|
449
|
+
return segmentError(404, 'twin_gap', `[twin gap] ${op.method} ${op.path} (${op.id}) is in the segment twin's ratified surface but not yet modeled — failing loudly instead of faking success. DEVIATION: the real Segment answers 200 to this request; a 200 that stores nothing is indistinguishable from acceptance, so this twin refuses instead. Grounding: ${op.evidence.slice(0, 120)}`);
|
|
450
|
+
}
|
|
451
|
+
// ── SEMANTICS ───────────────────────────────────────────────────────────────────────────────
|
|
452
|
+
// One entry per DEMANDED op (var/line/segment/DEMAND.json, measured in
|
|
453
|
+
// var/line/segment/DEMAND-EVIDENCE.json). Every handler receives the decoded envelope as `body`.
|
|
454
|
+
const direct = (type) => async ({ op, body, root, occurredAt }) => {
|
|
455
|
+
const env = body;
|
|
456
|
+
// THE ROUTE WINS, and that is the safer of two undocumented readings. The vendor's own worked
|
|
457
|
+
// examples carry the matching "type" in the payload ('#### OAuth' posts {"type": "track"} to
|
|
458
|
+
// /v1/track) and it never says what a DISAGREEING type means. Letting the body decide would
|
|
459
|
+
// mean a `{"type": "group", "groupId": …}` body posted to /v1/track silently folds a group
|
|
460
|
+
// from the track endpoint — a payload field redirecting an addressed operation. The endpoint
|
|
461
|
+
// is the operation the caller addressed, so it names the type.
|
|
462
|
+
const messages = env.messages.map((m) => ({ ...m, type }));
|
|
463
|
+
return ingest({ ...env, messages }, op.path, root, occurredAt);
|
|
464
|
+
};
|
|
465
|
+
export const SEMANTICS = {
|
|
466
|
+
// POST /v1/batch — the one route every official server SDK sends to. Per-MESSAGE dispatch.
|
|
467
|
+
batch: async ({ op, body, root, occurredAt }) => ingest(body, op.path, root, occurredAt),
|
|
468
|
+
// The direct per-type routes the vendor's HTTP Tracking API documents. Same folding path as a
|
|
469
|
+
// batched message of that type, by construction.
|
|
470
|
+
track: direct('track'),
|
|
471
|
+
identify: direct('identify'),
|
|
472
|
+
page: direct('page'),
|
|
473
|
+
group: direct('group'),
|
|
474
|
+
alias: direct('alias'),
|
|
475
|
+
};
|
|
476
|
+
// ── the request loop ────────────────────────────────────────────────────────────────────────
|
|
477
|
+
/** Decode a request body into the shared envelope. `/v1/batch` carries `{batch: [...]}`; a
|
|
478
|
+
* direct route carries ONE message at the top level, with the writeKey alongside it. */
|
|
479
|
+
export function decodeEnvelope(parsed, auth, isBatchRoute) {
|
|
480
|
+
const rootObj = obj(parsed);
|
|
481
|
+
if (!rootObj)
|
|
482
|
+
return undefined;
|
|
483
|
+
if (isBatchRoute) {
|
|
484
|
+
if (!Array.isArray(rootObj.batch))
|
|
485
|
+
return undefined;
|
|
486
|
+
return {
|
|
487
|
+
messages: rootObj.batch,
|
|
488
|
+
context: obj(rootObj.context),
|
|
489
|
+
integrations: obj(rootObj.integrations),
|
|
490
|
+
writeKey: auth.writeKey,
|
|
491
|
+
scheme: auth.scheme,
|
|
492
|
+
sentAt: str(rootObj.sentAt) ?? null,
|
|
493
|
+
batched: true,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
messages: [rootObj],
|
|
498
|
+
context: undefined,
|
|
499
|
+
integrations: undefined,
|
|
500
|
+
writeKey: auth.writeKey,
|
|
501
|
+
scheme: auth.scheme,
|
|
502
|
+
sentAt: str(rootObj.sentAt) ?? null,
|
|
503
|
+
batched: false,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
export async function handleSegmentTwinRequest(req) {
|
|
507
|
+
const url = new URL(req.path, 'http://twin.local');
|
|
508
|
+
const match = matchOp({ method: req.method, path: url.pathname + url.search, body: req.body });
|
|
509
|
+
if (!match) {
|
|
510
|
+
return segmentError(404, 'unknown_endpoint', `unknown endpoint: ${req.method.toUpperCase()} ${url.pathname} — not an operation in the segment twin's ratified surface (var/line/segment/SURFACE.json)`);
|
|
511
|
+
}
|
|
512
|
+
const { op } = match;
|
|
513
|
+
const handler = SEMANTICS[op.id];
|
|
514
|
+
if (!handler)
|
|
515
|
+
return gap(op);
|
|
516
|
+
const raw = req.body ?? '';
|
|
517
|
+
const size = byteLength(raw);
|
|
518
|
+
const isBatchRoute = op.id === 'batch';
|
|
519
|
+
// "There is a maximum of 32KB per normal API request. The batch API endpoint accepts a maximum
|
|
520
|
+
// of 500KB per request... Segment's API responds with 400 Bad Request if these limits are
|
|
521
|
+
// exceeded." Checked on the RAW BYTES before parsing: an oversize body must not be parsed at all.
|
|
522
|
+
const cap = isBatchRoute ? SEGMENT_MAX_BATCH_BYTES : SEGMENT_MAX_REQUEST_BYTES;
|
|
523
|
+
if (size > cap) {
|
|
524
|
+
return segmentError(400, 'payload_too_large', `payload of ${size} bytes exceeds the ${cap}-byte maximum for ${op.method} ${op.path}`);
|
|
525
|
+
}
|
|
526
|
+
let parsed;
|
|
527
|
+
try {
|
|
528
|
+
parsed = JSON.parse(raw);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// "If you send an event with invalid JSON, Segment returns a 400 Bad Request error."
|
|
532
|
+
return segmentError(400, 'invalid_json', 'request body is not valid JSON');
|
|
533
|
+
}
|
|
534
|
+
const auth = resolveWriteKey(req.authorization, parsed);
|
|
535
|
+
const env = decodeEnvelope(parsed, auth, isBatchRoute);
|
|
536
|
+
if (!env) {
|
|
537
|
+
return segmentError(400, 'invalid_payload', isBatchRoute
|
|
538
|
+
? 'POST /v1/batch requires a JSON object with a `batch` ARRAY of messages'
|
|
539
|
+
: `${op.method} ${op.path} requires a single JSON message object`);
|
|
540
|
+
}
|
|
541
|
+
// The serve path's only clock: the WORLD instant (R9 pins it as state), never wall time.
|
|
542
|
+
return handler({ op, params: match.params, query: match.query, body: env, root: req.root, occurredAt: req.occurredAt ?? worldNow() });
|
|
543
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-segment",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Segment twin - the HTTP Tracking API (POST /v1/batch and the direct /v1/{track,identify,page,screen,group,alias} routes) modeled over kernel state, so an unmodified @segment/analytics-node client round-trips offline. Built on @volter/world-core.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"client",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"!**/*.test.ts",
|
|
13
|
+
"!**/*.test.tsx",
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
19
|
+
"directory": "packages/twin/segment"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/segment#readme",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/src/index.d.ts",
|
|
26
|
+
"default": "./dist/src/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"world-segment": "dist/src/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "bun test src/*.test.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
36
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
37
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"react": "^19.2.7",
|
|
41
|
+
"react-dom": "^19.2.7"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@volter/world-core": "2.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@segment/analytics-node": "^2.3.0",
|
|
48
|
+
"@types/bun": "^1.2.20",
|
|
49
|
+
"@types/node": "^24.0.0",
|
|
50
|
+
"@types/react": "^19.2.17",
|
|
51
|
+
"@types/react-dom": "^19.2.3",
|
|
52
|
+
"@volter/world-core": "2.0.0",
|
|
53
|
+
"@volter/world-tooling": "0.1.0",
|
|
54
|
+
"typescript": "^5.9.0"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { optionValue } from '@volter/world-core/args';
|
|
3
|
+
import { createSegmentTwinServer } from './segment-server.ts';
|
|
4
|
+
import { OPS } from './segment-surface.gen.ts';
|
|
5
|
+
|
|
6
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
7
|
+
if (cmd === 'serve') {
|
|
8
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
9
|
+
const root = optionValue(rest, '--root') || process.env.VOLTER_STATE_DIR;
|
|
10
|
+
const server = await createSegmentTwinServer({ port, root });
|
|
11
|
+
console.log(`segment twin listening on ${server.url}`);
|
|
12
|
+
} else if (cmd === 'mirror') {
|
|
13
|
+
// The Source Debugger, and the tracking API behind it, on ONE origin — the screen and its data
|
|
14
|
+
// cannot drift onto different ports.
|
|
15
|
+
const { createSegmentMirrorServer } = await import('./segment-mirror-ui.ts');
|
|
16
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
17
|
+
const root = optionValue(rest, '--root') || process.env.VOLTER_STATE_DIR;
|
|
18
|
+
const server = await createSegmentMirrorServer({ port, root });
|
|
19
|
+
console.log(`segment Source Debugger + tracking API listening on http://127.0.0.1:${server.port}`);
|
|
20
|
+
} else if (cmd === 'conformance') {
|
|
21
|
+
// Lazily imported so the dev-only conformance module never enters the runtime entrypoint graph.
|
|
22
|
+
const { checkSegmentConformance } = await import('./segment-conformance.ts');
|
|
23
|
+
const report = await checkSegmentConformance({ root: process.env.VOLTER_STATE_DIR });
|
|
24
|
+
console.log(JSON.stringify(report, null, 2));
|
|
25
|
+
process.exit(report.ok ? 0 : 1);
|
|
26
|
+
} else if (cmd === 'ops') {
|
|
27
|
+
for (const op of OPS) console.log(`${op.method.padEnd(6)} ${op.path} [${op.id}]`);
|
|
28
|
+
} else {
|
|
29
|
+
console.log('usage: world-segment serve [--port N] [--root DIR] | mirror [--port N] [--root DIR] | conformance | ops');
|
|
30
|
+
}
|