@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.
Files changed (43) hide show
  1. package/README.md +154 -0
  2. package/client/segment-mirror.css +45 -0
  3. package/client/segment-mirror.tsx +154 -0
  4. package/dist/client/segment-mirror.bundle.js +342 -0
  5. package/dist/client/segment-mirror.css +45 -0
  6. package/dist/client/segment-mirror.d.ts +34 -0
  7. package/dist/client/segment-mirror.js +80 -0
  8. package/dist/client/segment-mirror.tsx +154 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +34 -0
  11. package/dist/src/index.d.ts +8 -0
  12. package/dist/src/index.js +53 -0
  13. package/dist/src/segment-budget.d.ts +41 -0
  14. package/dist/src/segment-budget.js +112 -0
  15. package/dist/src/segment-capabilities.d.ts +12 -0
  16. package/dist/src/segment-capabilities.gen.d.ts +3 -0
  17. package/dist/src/segment-capabilities.gen.js +22 -0
  18. package/dist/src/segment-capabilities.js +907 -0
  19. package/dist/src/segment-conformance.d.ts +8 -0
  20. package/dist/src/segment-conformance.js +106 -0
  21. package/dist/src/segment-connector.d.ts +76 -0
  22. package/dist/src/segment-connector.js +226 -0
  23. package/dist/src/segment-mirror-ui.d.ts +42 -0
  24. package/dist/src/segment-mirror-ui.js +143 -0
  25. package/dist/src/segment-server.d.ts +25 -0
  26. package/dist/src/segment-server.js +90 -0
  27. package/dist/src/segment-surface.gen.d.ts +48 -0
  28. package/dist/src/segment-surface.gen.js +267 -0
  29. package/dist/src/segment-twin.d.ts +98 -0
  30. package/dist/src/segment-twin.js +543 -0
  31. package/package.json +59 -0
  32. package/src/cli.ts +30 -0
  33. package/src/index.ts +87 -0
  34. package/src/segment-budget.ts +138 -0
  35. package/src/segment-capabilities.gen.ts +25 -0
  36. package/src/segment-capabilities.ts +988 -0
  37. package/src/segment-conformance.ts +123 -0
  38. package/src/segment-connector.ts +233 -0
  39. package/src/segment-journey.uitest.ts +116 -0
  40. package/src/segment-mirror-ui.ts +157 -0
  41. package/src/segment-server.ts +95 -0
  42. package/src/segment-surface.gen.ts +277 -0
  43. 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
+ }