@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,123 @@
1
+ // Segment conformance — vendor-property checks over the modeled tracking plane. Each check
2
+ // asserts a property of SEGMENT (a status, an envelope shape, a stored VALUE, a documented
3
+ // refusal), never twin self-consistency, and each goes red if its handler is deleted.
4
+ //
5
+ // The third direction the probe⇄claim pair is blind to is closed by ROUTER_SURFACE below: every
6
+ // method/path pair the ratified surface declares is exercised, and anything that answers neither
7
+ // a modeled outcome nor the vendor-shaped loud gap is a failure. That is what catches
8
+ // served-but-unclaimed surface (the googleoauth precedent, ADDING_A_TWIN §6).
9
+ import { OPS } from './segment-surface.gen.ts';
10
+ import { events, groups, handleSegmentTwinRequest, identities } from './segment-twin.ts';
11
+
12
+ export type SegmentConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
13
+
14
+ const KEY = 'conformance_write_key';
15
+ const body = (o: unknown) => JSON.stringify(o);
16
+
17
+ /** The five modeled ops, as a LITERAL. Deliberately not derived from SEMANTICS: two constants
18
+ * asserting about each other is not a check (the tinybird shape, ADDING_A_TWIN §6) — deleting a
19
+ * handler must make this table disagree with the router, not follow it. */
20
+ const MODELED_OPS = new Set(['batch', 'track', 'identify', 'page', 'group', 'alias']);
21
+
22
+ export async function checkSegmentConformance(options: { root?: string } = {}): Promise<SegmentConformanceReport> {
23
+ const failures: string[] = [];
24
+ // COUNTED, never a literal: a hardcoded `checksRun` is satisfied by a report that ran FEWER
25
+ // checks than it claims, so deleting a check would leave the suite green — the
26
+ // two-constants-asserting-about-each-other shape (var/line/mixpanel/REVIEW.md F8).
27
+ let checksRun = 0;
28
+ const check = (name: string, ok: boolean) => { checksRun += 1; if (!ok) failures.push(name); };
29
+ const h = (method: string, path: string, payload?: string, authorization?: string) =>
30
+ handleSegmentTwinRequest({ method, path, body: payload, authorization, root: options.root });
31
+
32
+ // 1. The envelope the unmodified SDK sends is accepted with a 2xx — the ONLY thing
33
+ // @segment/analytics-node@2 reads (`response.status >= 200 && response.status < 300`).
34
+ const flushed = await h('POST', '/v1/batch', body({
35
+ batch: [
36
+ { type: 'identify', userId: 'conf_user', traits: { plan: 'pro' }, messageId: 'conf_m1' },
37
+ { type: 'track', userId: 'conf_user', event: 'Conformance Ran', properties: { n: 1 }, messageId: 'conf_m2' },
38
+ ],
39
+ writeKey: KEY,
40
+ sentAt: '2026-08-24T00:00:00.000Z',
41
+ }));
42
+ check('POST /v1/batch answers 2xx for the SDK envelope', flushed.status >= 200 && flushed.status < 300);
43
+ check('every message in the batch folded, not just batch[0]', events(options.root).filter((e) => ['conf_m1', 'conf_m2'].includes(String(e.messageId))).length === 2);
44
+ check('the identify folded its traits into an identity', (identities(options.root).find((i) => i.id === 'conf_user')?.traits as Record<string, unknown> | undefined)?.plan === 'pro');
45
+ check('the track kept its event name and properties', events(options.root).some((e) => e.event === 'Conformance Ran' && (e.properties as Record<string, unknown>).n === 1));
46
+ check('the writeKey travelled from the ENVELOPE onto each message', events(options.root).find((e) => e.messageId === 'conf_m2')?.writeKey === KEY);
47
+
48
+ // 2. Basic auth with the write key as the USERNAME and an empty password — analytics-node v1's
49
+ // wire shape, documented at http-api/index.md '#### Basic authentication'.
50
+ await h('POST', '/v1/track', body({ userId: 'conf_basic', event: 'Basic Auth', messageId: 'conf_m3' }), `Basic ${Buffer.from('basic_key:', 'utf8').toString('base64')}`);
51
+ const basic = events(options.root).find((e) => e.messageId === 'conf_m3');
52
+ check('Basic <base64(writeKey:)> resolves the write key from the header', basic?.writeKey === 'basic_key' && basic?.authScheme === 'basic');
53
+
54
+ // 3. A group call folds a group with its traits; an alias links previousId onto the userId.
55
+ await h('POST', '/v1/group', body({ userId: 'conf_user', groupId: 'conf_group', traits: { name: 'Initech' }, writeKey: KEY, messageId: 'conf_m4' }));
56
+ check('POST /v1/group folds group traits', (groups(options.root).find((g) => g.id === 'conf_group')?.traits as Record<string, unknown> | undefined)?.name === 'Initech');
57
+ await h('POST', '/v1/alias', body({ userId: 'conf_user', previousId: 'conf_anon', writeKey: KEY, messageId: 'conf_m5' }));
58
+ check('POST /v1/alias records previousId on the identity', ((identities(options.root).find((i) => i.id === 'conf_user')?.previousIds as string[] | undefined) ?? []).includes('conf_anon'));
59
+
60
+ // 4. The documented ACCEPT-AND-DROP: "The HTTP API requires that each payload has a userId
61
+ // and/or anonymousId... Segment's tracking API responds with an no_user_anon_id error", and
62
+ // the page's own rule is that everything but oversize/invalid-JSON answers 200.
63
+ const before = events(options.root).length;
64
+ const anon = await h('POST', '/v1/track', body({ event: 'No Identifier', writeKey: KEY, messageId: 'conf_m6' }));
65
+ check('a payload with neither userId nor anonymousId still answers 200', anon.status === 200);
66
+ check('...and is NOT folded into the event feed', events(options.root).length === before);
67
+
68
+ // 5. "If you send an event with invalid JSON, Segment returns a 400 Bad Request error" — and
69
+ // the body is {code, message}, the shape analytics-python parses off any non-200.
70
+ const bad = await h('POST', '/v1/batch', '{not json');
71
+ const badBody = bad.body as { code?: unknown; message?: unknown };
72
+ check('invalid JSON answers 400 with the vendor {code, message} envelope', bad.status === 400 && typeof badBody.code === 'string' && typeof badBody.message === 'string');
73
+
74
+ // 6. "There is a maximum of 32KB per normal API request... Segment's API responds with 400 Bad
75
+ // Request if these limits are exceeded."
76
+ const oversize = await h('POST', '/v1/track', body({ userId: 'u', event: 'Big', properties: { blob: 'x'.repeat(33 * 1024) }, writeKey: KEY }));
77
+ check('a >32KB direct request answers 400', oversize.status === 400);
78
+
79
+ // 7. A ratified-but-unmodeled op fails LOUDLY by name — never the vendor's blanket 200, which
80
+ // would be indistinguishable from success.
81
+ const gap = await h('POST', '/v1/screen', body({ userId: 'u', name: 'Home', writeKey: KEY }));
82
+ const gapMsg = String((gap.body as { message?: unknown }).message ?? '');
83
+ check('an unmodeled route answers the loud [twin gap] naming the op', gap.status === 404 && gapMsg.includes('[twin gap]') && gapMsg.includes('screen'));
84
+
85
+ // 8. The sharper one: an unmodeled message TYPE riding INSIDE a modeled route. `screen` is a
86
+ // type the unmodified SDK can put in a /v1/batch envelope, so the per-message dispatcher has
87
+ // to refuse it by name rather than inherit /v1/batch's success.
88
+ const countBefore = events(options.root).length;
89
+ const mixed = await h('POST', '/v1/batch', body({ batch: [{ type: 'track', userId: 'u', event: 'Rides Along', messageId: 'conf_m7' }, { type: 'screen', userId: 'u', name: 'Home', messageId: 'conf_m8' }], writeKey: KEY }));
90
+ const mixedMsg = String((mixed.body as { message?: unknown }).message ?? '');
91
+ check('an unmodeled TYPE inside a modeled batch fails loudly by name', mixed.status === 404 && mixedMsg.includes('screen'));
92
+ check('...and nothing from that batch was stored', events(options.root).length === countBefore);
93
+
94
+ // 9. "Segment deduplicates events using the messageId field."
95
+ await h('POST', '/v1/track', body({ userId: 'conf_user', event: 'Deduped', writeKey: KEY, messageId: 'conf_dupe' }));
96
+ await h('POST', '/v1/track', body({ userId: 'conf_user', event: 'Deduped Again', writeKey: KEY, messageId: 'conf_dupe' }));
97
+ check('a repeated messageId folds exactly once', events(options.root).filter((e) => e.messageId === 'conf_dupe').length === 1);
98
+
99
+ // 10. Non-ASCII survives byte for byte. In-process fixtures can hide an encoding bug the wire
100
+ // exposes, so this value is also driven through the real SDK in the integration suite.
101
+ await h('POST', '/v1/track', body({ userId: 'conf_uni', event: 'Café ☕', properties: { name: '日本語', emoji: '🎉' }, writeKey: KEY, messageId: 'conf_uni' }));
102
+ const uni = events(options.root).find((e) => e.messageId === 'conf_uni');
103
+ check('a non-ASCII payload round-trips unmangled', uni?.event === 'Café ☕' && (uni.properties as Record<string, unknown>).name === '日本語');
104
+
105
+ // 11. Unknown route: the vendor-shaped error envelope, not a bare string or an HTML page.
106
+ const unknown = await h('GET', '/never-a-real-endpoint');
107
+ const unknownBody = unknown.body as { code?: unknown; message?: unknown };
108
+ check('an unknown route answers 404 with the {code, message} envelope', unknown.status === 404 && typeof unknownBody.code === 'string' && typeof unknownBody.message === 'string');
109
+
110
+ // 12. ROUTER_SURFACE: every ratified op is exercised, and each must answer EITHER a modeled
111
+ // outcome or the loud gap. A route that is served but not claimed here shows up as a
112
+ // modeled-looking answer for an op MODELED_OPS does not list.
113
+ let surfaceOk = true;
114
+ for (const op of OPS) {
115
+ const probe = await h(op.method, op.path, op.method === 'GET' ? undefined : body({ userId: 'probe_user', event: 'Probe', groupId: 'g', previousId: 'p', batch: [], writeKey: KEY }));
116
+ const msg = String((probe.body as { message?: unknown } | undefined)?.message ?? '');
117
+ const isGap = probe.status === 404 && msg.includes('[twin gap]');
118
+ if (MODELED_OPS.has(op.id) ? isGap : !isGap) surfaceOk = false;
119
+ }
120
+ check('every ratified op answers either a modeled outcome or the loud gap, and no other', surfaceOk);
121
+
122
+ return { ok: failures.length === 0, checksRun, failures };
123
+ }
@@ -0,0 +1,233 @@
1
+ // Segment CONNECTOR — pull/push against an INJECTED `SegmentExecute`, so every test and every
2
+ // capability verify runs offline against a deterministic fake (the repo-wide connector
3
+ // discipline: the real network exists only inside liveSegmentExecute).
4
+ //
5
+ // v1 scope, honestly: PUSH replays locally-ingested messages back through the injected execute
6
+ // onto the vendor's real POST /v1/batch. PULL mirrors NOTHING, and that is a FACT about this
7
+ // vendor rather than an unfinished job: Segment's tracking plane has NO read-back operation at
8
+ // all. Every one of the sixteen ratified ops in var/line/segment/SURFACE.json is a WRITE (fifteen
9
+ // ingestion routes plus an OAuth token exchange); the vendor's own answer to "did my event land"
10
+ // is the browser Source Debugger, a live websocket view (segment-docs src/connections/sources/
11
+ // debugger.md), not an API. The nearest read surface is the Profile API on a DIFFERENT host
12
+ // (profiles.segment.com/v1/spaces/{spaceId}/collections/users/profiles/{id}/{traits,events,
13
+ // external_ids,metadata,links}) behind a DIFFERENT credential (an Engage access token as HTTP
14
+ // Basic username) and a different product tier, and it returns resolved PROFILES, never the raw
15
+ // ingested events this twin folds. So there is nothing on the modeled plane to mirror. The gap is
16
+ // FILED as the segment.connector.pull todo in segment-capabilities.ts and ruled in
17
+ // var/line/segment/RULINGS.json (denominator:profile-api-is-the-only-read-back) — never faked
18
+ // with an empty resource list, because a connector that maps "no read path" to "an empty account"
19
+ // hands syncPull an empty account to fold over real observed state (ADDING_A_TWIN §6).
20
+ import { assertBudgetGuardIntact, assertFastForward, confirmAction, NonFastForwardPushError, pendingActions, syncPull } from '@volter/world-core';
21
+ import type { SyncResource, TwinAction } from '@volter/world-core';
22
+ import { SegmentBudget, SegmentBudgetError, segmentCallWeight, type SegmentBudgetOptions } from './segment-budget.ts';
23
+
24
+ export type SegmentExecute = (req: { method: string; path: string; body?: unknown }) => Promise<{ status: number; data: unknown }>;
25
+
26
+ const SERVICE = 'segment';
27
+
28
+ /** The real Segment tracking host. The ONLY place this pack names it as a live target. */
29
+ export const SEGMENT_API_BASE = 'https://api.segment.io';
30
+
31
+ /**
32
+ * THE CHOKE POINT — the one place this pack builds a `SegmentExecute` that really reaches
33
+ * api.segment.io. Reads the source write key from `SEGMENT_WRITE_KEY` (never a literal).
34
+ *
35
+ * AUTH: this sends the scheme the PINNED SDK sends, not the scaffold's guess. @segment/
36
+ * analytics-node@2 puts the write key IN THE BODY and sets no Authorization header at all
37
+ * (src/plugins/segmentio/publisher.ts:230-247 — Content-Type and User-Agent only, plus a Bearer
38
+ * header ONLY when oauthSettings are configured), and the vendor documents that as its first
39
+ * auth option: "The authentication writeKey should be sent as part of the body of the request...
40
+ * For this auth type, you do not need to set any authentication header." A `Bearer <writeKey>`
41
+ * header — what the S4 scaffold emits by default — is not one of the three documented schemes and
42
+ * would authenticate nothing (ruled: denominator:auth-is-three-documented-schemes-not-one). So
43
+ * the key is STAMPED INTO THE ENVELOPE here, and `sentAt` alongside it, exactly as the SDK does.
44
+ * No vendor SDK is imported: real-transport `fetch` keeps the pack SDK-free at runtime (B3).
45
+ */
46
+ export function liveSegmentExecute(
47
+ opts: {
48
+ apiKey?: string;
49
+ baseUrl?: string;
50
+ fetchImpl?: typeof fetch;
51
+ /** An existing budget to share across executes. Omit and one is constructed. Cannot be null. */
52
+ budget?: SegmentBudget;
53
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
54
+ budgetOptions?: SegmentBudgetOptions;
55
+ /** Frozen clock for the envelope's `sentAt`; tests inject it, production omits it. */
56
+ now?: () => Date;
57
+ } = {},
58
+ ): SegmentExecute {
59
+ const apiKey = opts.apiKey ?? process.env.SEGMENT_WRITE_KEY;
60
+ const base = (opts.baseUrl ?? SEGMENT_API_BASE).replace(/\/$/, '');
61
+ const doFetch = opts.fetchImpl ?? fetch;
62
+ const now = opts.now ?? (() => new Date());
63
+ if (!apiKey) throw new Error('liveSegmentExecute: SEGMENT_WRITE_KEY is not set (never pass a literal)');
64
+ // No caller value yields an unguarded execute: omitted/null builds the default; anything else
65
+ // must be an UNMODIFIED SegmentBudget. `instanceof` alone is NOT a check — a one-line subclass
66
+ // or a Proxy trapping `get` satisfies it and disables the ceiling — so the kernel's
67
+ // method-identity assertion is used instead (docs/contributing/architecture.md D8).
68
+ const budget = opts.budget !== undefined && opts.budget !== null
69
+ ? assertBudgetGuardIntact(opts.budget, SegmentBudget, 'liveSegmentExecute')
70
+ : new SegmentBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
71
+ return async ({ method, path, body }) => {
72
+ const weight = segmentCallWeight(method, path);
73
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
74
+ const reservation = budget.checkBudget(weight);
75
+ const payload = body === undefined ? undefined : stampEnvelope(body, apiKey, now());
76
+ const res = await doFetch(`${base}${path}`, {
77
+ method,
78
+ // "To send data to Segment's HTTP API, a content-type header must be set to
79
+ // 'application/json'." (http-api/index.md '### Content-Type')
80
+ headers: payload === undefined ? {} : { 'content-type': 'application/json' },
81
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
82
+ });
83
+ const headers: Record<string, string> = {};
84
+ res.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
85
+ const text = await res.text();
86
+ // Settles the reservation; a 429/Retry-After arms the persisted cooldown (and may throw).
87
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
88
+ // call that louder refusal wins; an answer Segment ACCEPTED is kept, so a write that landed is
89
+ // never recorded as failed and performed again on retry.
90
+ try {
91
+ budget.recordCall(weight, headers, { status: res.status, reservation });
92
+ } catch (error) {
93
+ if (!(error instanceof SegmentBudgetError) || !res.ok) throw error;
94
+ }
95
+ let data: unknown = null;
96
+ if (text) { try { data = JSON.parse(text); } catch { data = text; } }
97
+ return { status: res.status, data };
98
+ };
99
+ }
100
+
101
+ /** The auth step the docblock above promises, made real: the write key rides INSIDE the envelope
102
+ * (`writeKey`), with `sentAt` beside it, exactly as @segment/analytics-node@2's Publisher builds
103
+ * it. Only an ABSENT key is filled — an envelope that already names one (a different source, a
104
+ * caller override) is left exactly as it is. This is the mixpanel F7 class, pre-empted: a
105
+ * docblock claiming a stamping no call site performed sent every replayed message unauthenticated. */
106
+ export function stampEnvelope(body: unknown, writeKey: string, sentAt: Date): unknown {
107
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return body;
108
+ const env = body as Record<string, unknown>;
109
+ return {
110
+ ...env,
111
+ ...(typeof env.writeKey === 'string' && env.writeKey ? {} : { writeKey }),
112
+ ...(typeof env.sentAt === 'string' && env.sentAt ? {} : { sentAt: sentAt.toISOString() }),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * A pull's `occurredAt` must MOVE, never be pinned. The kernel hashes an observed event over
118
+ * (`occurredAt` + post-state), so under a fixed poll time a vendor value that REVERTS (A->B->A
119
+ * across polls) collides with its own earlier observation: `syncPull` reports a delta while
120
+ * nothing lands, and the projection keeps serving the stale value. Wall clock alone is not quite
121
+ * enough either — two polls inside one millisecond hand the kernel the same stamp — so it is
122
+ * forced strictly increasing within the process (the dynadot `pollTimestamp` precedent).
123
+ */
124
+ let lastPollAt = 0;
125
+ function pollTimestamp(): string {
126
+ const now = Math.max(Date.now(), lastPollAt + 1);
127
+ lastPollAt = now;
128
+ return new Date(now).toISOString();
129
+ }
130
+
131
+ /** D7 entry point. v1 pulls nothing — see the file header for the vendor fact that makes it so,
132
+ * and segment-capabilities.ts's `segment.connector.pull` todo for the filed gap. `syncPull` is
133
+ * still called with the (empty) resource list so the shadow-diff contract holds and a later
134
+ * version only has to fill the array. */
135
+ export async function syncSegmentFromReal(_execute: SegmentExecute, options: { root?: string; occurredAt?: string } = {}): Promise<{ pulled: number }> {
136
+ const resources: SyncResource[] = [];
137
+ // Kernel API takes an OPTIONS OBJECT; occurredAt is required (positional args die at runtime).
138
+ syncPull({
139
+ service: SERVICE,
140
+ resources,
141
+ occurredAt: options.occurredAt ?? pollTimestamp(),
142
+ ...(options.root !== undefined ? { root: options.root } : {}),
143
+ });
144
+ return { pulled: resources.length };
145
+ }
146
+
147
+ /** Replay locally-ingested messages through the injected execute (push half of D7). Sends the
148
+ * vendor's own batch envelope — `{batch: [<message>...], writeKey, sentAt}` — which is exactly
149
+ * what @segment/analytics-node@2's Publisher puts on the wire, so the replay is byte-shaped like
150
+ * a real SDK flush rather than like this twin's internal rows. */
151
+ export async function pushPendingSegmentActions(
152
+ execute: SegmentExecute,
153
+ options: { root?: string; writeKey?: string } = {},
154
+ ): Promise<{ pushed: number; failed: number; refused: number }> {
155
+ let pushed = 0;
156
+ let failed = 0;
157
+ let refused = 0;
158
+ for (const action of pendingActions(SERVICE, options.root) as TwinAction[]) {
159
+ // Only ingested flushes are pushable in v1; a `drop.messages` action records messages the
160
+ // VENDOR would have rejected, so replaying them would re-send data Segment already refused.
161
+ if (!action.operation?.startsWith('ingest.')) continue;
162
+ // R14 non-fast-forward gate (THE reference enrollment for the connector sweep): the mirror
163
+ // moving past this action's stamped basis means someone else changed the remote since it was
164
+ // authored — pushing would overwrite them. The action is SKIPPED (stays pending, per-ref like
165
+ // git), counted, and the operator fetches + reconciles, then reverts/re-authors or pushes
166
+ // with a reviewed force through the kernel seam. The check sits IMMEDIATELY before the
167
+ // vendor write: any later and the refusal arrives after the damage.
168
+ try {
169
+ assertFastForward(SERVICE, action.id, options.root);
170
+ } catch (error) {
171
+ if (error instanceof NonFastForwardPushError) { refused += 1; continue; }
172
+ throw error;
173
+ }
174
+ const envelope = pushEnvelopeFor(action, options.writeKey);
175
+ const res = await execute({ method: 'POST', path: '/v1/batch', body: envelope });
176
+ // "Segment returns a 200 response for all API requests except errors caused by large payloads
177
+ // and JSON errors (which return 400 responses.)" — so any 2xx is acceptance of the REQUEST.
178
+ // Anything else leaves the action pending rather than silently confirming it.
179
+ if (res.status >= 200 && res.status < 300) {
180
+ // The kernel's confirmAction takes an OPTIONS OBJECT — it records the pushed fields as an
181
+ // OBSERVED event and maps the local action onto it, so the change is counted exactly once.
182
+ //
183
+ // AND IT HAS TO CARRY THE WHOLE FLUSH. `projectResources` SUPPRESSES a confirmed action's
184
+ // projection outright — `if (reverted.has(a.id) || confirmed.has(a.id)) continue`
185
+ // (control-plane/src/actions.ts) — and rebuilds that state from the observed events the
186
+ // confirm wrote instead. A Segment flush is ONE action whose projection carries EVERY
187
+ // message of the batch plus the identities and groups they folded, so confirming with
188
+ // `fields` alone observes batch[0] only and DELETES the rest of the flush from the
189
+ // projection the moment it is pushed. Every other resource of the action therefore rides in
190
+ // `additionalObservations`, the kernel's own seam for a compound action
191
+ // (npm-registry-connector.ts is the precedent).
192
+ const primaryKey = `${action.subject.type}:${action.subject.id}`;
193
+ const additionalObservations = (action.projection?.creates ?? [])
194
+ .filter((c) => `${c.type}:${c.id}` !== primaryKey)
195
+ .map((c) => ({ subject: { type: c.type, id: c.id }, fields: c.fields }));
196
+ confirmAction({
197
+ service: SERVICE,
198
+ actionId: action.id,
199
+ subject: action.subject,
200
+ fields: action.fields ?? {},
201
+ ...(additionalObservations.length ? { additionalObservations } : {}),
202
+ occurredAt: new Date().toISOString(),
203
+ ...(options.root ? { root: options.root } : {}),
204
+ });
205
+ pushed += 1;
206
+ } else failed += 1;
207
+ }
208
+ return { pushed, failed, refused };
209
+ }
210
+
211
+ /** Rebuild the vendor ENVELOPE this action recorded. The twin folds one flush into ONE action
212
+ * whose `projection.creates` carries every event row of that flush, so the replay reconstructs
213
+ * the same `{batch: [...]}` the SDK originally sent rather than exploding it into single-message
214
+ * requests. Two traps this closes:
215
+ * • the twin stores the discriminator as `messageType`, because the kernel's META set silently
216
+ * drops a field literally named `type` (control-plane/src/actions.ts) — so the replay has to
217
+ * map it BACK to `type`, or every replayed message would reach the real vendor typeless;
218
+ * • `action.fields` alone is only the FIRST message of the flush, so reading it instead of the
219
+ * projection would silently push one message and confirm the whole batch. */
220
+ export function pushEnvelopeFor(action: TwinAction, writeKey?: string): Record<string, unknown> {
221
+ const creates = (action.projection?.creates ?? []).filter((c) => c.type === 'event');
222
+ const rows = creates.length ? creates.map((c) => c.fields as Record<string, unknown>) : [(action.fields ?? {}) as Record<string, unknown>];
223
+ const batch = rows.map((f) => {
224
+ const message: Record<string, unknown> = { type: f.messageType, messageId: f.messageId };
225
+ for (const key of ['userId', 'anonymousId', 'event', 'name', 'category', 'groupId', 'previousId', 'properties', 'traits', 'context', 'integrations', 'timestamp'] as const) {
226
+ if (f[key] !== null && f[key] !== undefined) message[key] = f[key];
227
+ }
228
+ return message;
229
+ });
230
+ const first = rows[0] ?? {};
231
+ const resolved = writeKey ?? (typeof first.writeKey === 'string' ? first.writeKey : undefined);
232
+ return { batch, ...(resolved ? { writeKey: resolved } : {}) };
233
+ }
@@ -0,0 +1,116 @@
1
+ // SEGMENT UI JOURNEY — the pack's registered read journey (ui-scope.json
2
+ // `segment.journey.debugger`). Seed real state through the pack's OWN write path
3
+ // (`handleSegmentTwinRequest` against the vendor's real `/v1/batch`, never a hand-written events
4
+ // log), boot the real mirror server (`createSegmentMirrorServer`) on an ephemeral port, and drive
5
+ // it with real headless Playwright chromium using role/text locators ONLY.
6
+ //
7
+ // Proves the journey the census names: open the Source Debugger and SEE the events a source
8
+ // received, with their type, label, subject and properties — and then switch to the Dropped stream
9
+ // and see the message that returned 200 and never arrived, with the REASON it was dropped. That
10
+ // second half is the whole point of the screen: the Segment tracking API answers 200 and then
11
+ // silently drops a message it cannot attribute, so without this stream "200 but missing" is
12
+ // invisible.
13
+ //
14
+ // Every step writes a filmstrip frame (the `visible`/`clickByName` helpers do this), which is the
15
+ // evidence a reviewer certifies the UI rung from.
16
+ //
17
+ // Must never SILENTLY skip: `requireBrowser` throws with the fix command when chromium is missing,
18
+ // unless the operator sets the loud opt-out env var.
19
+ import { describe, test } from 'bun:test';
20
+ import { requireBrowser, runUiJourney, JOURNEY_TIMEOUT_MS, visible, gone, clickByName } from '@volter/world-tooling';
21
+ import { handleSegmentTwinRequest } from './segment-twin.ts';
22
+ import { createSegmentMirrorServer } from './segment-mirror-ui.ts';
23
+
24
+ const JOURNEY_LABEL = 'segment UI journey';
25
+ const KEY = 'twin_journey_write_key';
26
+ const AUTH = `Basic ${Buffer.from(`${KEY}:`).toString('base64')}`;
27
+ const OCC = '2026-03-01T09:41:07.000Z';
28
+
29
+ /**
30
+ * Seed one batch through the vendor's REAL route: a named track with properties, a page view, an
31
+ * identify — and one message carrying neither `userId` nor `anonymousId`, which is the vendor's
32
+ * own documented drop and the row the second half of the journey looks for.
33
+ */
34
+ async function seedWorld(root: string) {
35
+ await handleSegmentTwinRequest({
36
+ method: 'POST',
37
+ path: '/v1/batch',
38
+ authorization: AUTH,
39
+ body: JSON.stringify({
40
+ writeKey: KEY,
41
+ sentAt: OCC,
42
+ batch: [
43
+ { type: 'track', userId: 'u_journey', event: 'Order Completed', properties: { revenue: 14.99, currency: 'USD' }, messageId: 'm_track' },
44
+ { type: 'page', userId: 'u_journey', name: 'Pricing', properties: { path: '/pricing' }, messageId: 'm_page' },
45
+ { type: 'identify', userId: 'u_journey', traits: { email: 'journey@example.test' }, messageId: 'm_identify' },
46
+ { type: 'track', event: 'Orphan Event', messageId: 'm_orphan' },
47
+ ],
48
+ }),
49
+ root,
50
+ occurredAt: OCC,
51
+ });
52
+ }
53
+
54
+ const serve = async (root: string) => {
55
+ const server = await createSegmentMirrorServer({ root, port: 0 });
56
+ return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
57
+ };
58
+
59
+ describe('segment UI journey', () => {
60
+ test('the Source Debugger streams the events the source received, with their properties', async () => {
61
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
62
+
63
+ await runUiJourney({
64
+ label: 'segment the source debugger streams the events the source received',
65
+ seed: seedWorld,
66
+ serve,
67
+ journey: async (page) => {
68
+ // 1. The accepted stream is the default screen and names each message the way the vendor
69
+ // does — a track by its event, a page by its name, an identify by its subject.
70
+ await visible(page, 'Order Completed');
71
+ await visible(page, 'Pricing');
72
+ await visible(page, 'u_journey');
73
+
74
+ // 2. The properties are on the row. This is what a debugger reader is actually after when
75
+ // an event "arrived but is wrong", and no header or nav renders these strings.
76
+ await visible(page, 'revenue');
77
+ await visible(page, '14.99');
78
+ await visible(page, 'currency');
79
+ await visible(page, 'USD');
80
+
81
+ // 3. The counts are live: three messages were attributed, one was not.
82
+ await visible(page, '3 accepted');
83
+ await visible(page, '1 dropped');
84
+ },
85
+ });
86
+ }, JOURNEY_TIMEOUT_MS);
87
+
88
+ test('the Dropped stream shows the message that returned 200 and never arrived, with its reason', async () => {
89
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
90
+
91
+ await runUiJourney({
92
+ label: 'segment the dropped stream shows the 200-but-never-arrived message and its reason',
93
+ seed: seedWorld,
94
+ serve,
95
+ journey: async (page) => {
96
+ await visible(page, 'Order Completed');
97
+
98
+ // The nav item is a real role-reachable <button>; its destination must show the row the
99
+ // twin recorded as REFUSED — a shell that rendered but read nothing would fail here.
100
+ await clickByName(page, /^Dropped$/);
101
+ await visible(page, 'no_user_anon_id');
102
+ await visible(page, '/v1/batch');
103
+ // ...and the accepted stream's content is GONE from this screen: two streams, not one
104
+ // list. A POSITIVE assertion on the word "dropped" was dead — the header renders
105
+ // "N dropped" on every screen and the nav button says "Dropped", so it passed with both
106
+ // streams deleted (§9). `gone` is the assertion that has teeth.
107
+ await gone(page, 'Order Completed');
108
+ await gone(page, 'Pricing');
109
+
110
+ // …and back to Accepted, which still has its live data (no unmount damage).
111
+ await clickByName(page, /^Accepted$/);
112
+ await visible(page, 'Pricing');
113
+ },
114
+ });
115
+ }, JOURNEY_TIMEOUT_MS);
116
+ });
@@ -0,0 +1,157 @@
1
+ // SEGMENT MIRROR UI — the SOURCE DEBUGGER, served as a React/TSX app (bundled by Bun).
2
+ //
3
+ // WHY THIS SCREEN AND NOT ANOTHER. The Segment HTTP Tracking API is WRITE-ONLY: every one of its
4
+ // sixteen ratified operations is an ingest, and none of them reads a message back. So the vendor's
5
+ // own answer to "my event returned 200 and never arrived" is not an API call — it is the Source
6
+ // Debugger, the live stream of what the source actually received. That asymmetry is exactly why
7
+ // ui-scope.json rules this vendor OWED a mirror, and it is why the debugger is the FIRST screen:
8
+ // without it a caller has a 200 and nothing else.
9
+ //
10
+ // DATA COUPLING. The client reads the twin's OWN store door (`GET /twin/store/events`,
11
+ // `…/dropped`), the kernel's read-only named-projection door and the sanctioned way a mirror reads
12
+ // twin state. There is no mirror-local view model and no second copy of the projection: what the
13
+ // screen shows is what `events(root)` folded from real ingests, so a caller who POSTs through the
14
+ // unmodified `@segment/analytics-node` client sees that message on this screen.
15
+ //
16
+ // THE DROPPED PANE IS THE POINT. Segment answers 200 and then silently drops a message it cannot
17
+ // attribute; this twin RECORDS the refusal (`dropped(root)`, with a reason). The debugger shows
18
+ // both streams side by side, so "returned 200 and never arrived" stops being invisible — which is
19
+ // the exact failure the vendor tells you to open this screen for.
20
+ //
21
+ // PURE FRONTEND (R3): this module imports no handler and no twin internals. It MOUNTS the pack's
22
+ // own fetch adapter as the API backend and the client reads every byte of state back over the wire.
23
+ import { readFile } from 'node:fs/promises';
24
+ import { bundleClient, fileResponse } from '@volter/world-core';
25
+ import { serveHttp } from '@volter/world-core';
26
+ import { createSegmentTwinFetch } from './segment-server.ts';
27
+
28
+ const CLIENT_ENTRY = () => new URL('../client/segment-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
29
+ const CLIENT_CSS = () => new URL('../client/segment-mirror.css', import.meta.url).pathname; // lazy: same
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Pure, dependency-free render/format helpers (importable by the React client; Bun tree-shakes
33
+ // the server-only exports out of the browser bundle). Free of @volter/world-core, Bun and the handler.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /** One row of the `events` / `dropped` store projections, as the door serves it. */
37
+ export type SegmentRow = Record<string, any>;
38
+
39
+ /** The tone a message-type pill gets. `alias` and `group` are the identity-plumbing calls, so they
40
+ * read differently from `track`, which is the one carrying a business event name. */
41
+ export type PillTone = 'track' | 'identity' | 'screenish' | 'dropped';
42
+
43
+ /** STATIC literal class names — a template-literal class (`pill-${tone}`) is erased by the
44
+ * bundler, so the mirror emits these and the verifies assert them (ADDING_A_TWIN §6). */
45
+ export const PILL_CLASS: Record<PillTone, string> = {
46
+ track: 'pill pill-track',
47
+ identity: 'pill pill-identity',
48
+ screenish: 'pill pill-screenish',
49
+ dropped: 'pill pill-dropped',
50
+ };
51
+
52
+ /** The tone for a message type. Pure function of the vendor's own six call types. */
53
+ export function typeTone(messageType: unknown): PillTone {
54
+ switch (String(messageType ?? '')) {
55
+ case 'track': return 'track';
56
+ case 'identify':
57
+ case 'alias':
58
+ case 'group': return 'identity';
59
+ default: return 'screenish';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The LABEL the debugger shows for a message — what the vendor's own stream leads each row with.
65
+ * A `track` is named by its `event`; a `page`/`screen` by its `name`; the identity calls by the
66
+ * subject they resolve. Never the message id, which tells a reader nothing.
67
+ */
68
+ export function messageLabel(row: SegmentRow): string {
69
+ const type = String(row.messageType ?? '');
70
+ if (type === 'track') return String(row.event ?? '(unnamed event)');
71
+ if (type === 'page' || type === 'screen') return String(row.name ?? '(unnamed page)');
72
+ if (type === 'group') return `group ${String(row.groupId ?? '?')}`;
73
+ if (type === 'alias') return `alias ${String(row.previousId ?? '?')} → ${String(row.userId ?? '?')}`;
74
+ return String(row.userId ?? row.anonymousId ?? '(anonymous)');
75
+ }
76
+
77
+ /** The subject a row is attributed to — `userId` when Segment could resolve one, else the
78
+ * anonymous id. A row with neither is precisely the one the vendor drops. */
79
+ export function subjectOf(row: SegmentRow): string {
80
+ const userId = row.userId === null || row.userId === undefined ? '' : String(row.userId);
81
+ if (userId !== '') return userId;
82
+ const anon = row.anonymousId === null || row.anonymousId === undefined ? '' : String(row.anonymousId);
83
+ return anon === '' ? '—' : `anon:${anon}`;
84
+ }
85
+
86
+ /** Flatten one level of a properties/traits object into `key=value` chips, sorted so a rendered
87
+ * row is byte-identical on replay. Nested values render as compact JSON. */
88
+ export function propertyChips(value: unknown): Array<{ key: string; value: string }> {
89
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return [];
90
+ return Object.entries(value as Record<string, unknown>)
91
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
92
+ .map(([key, v]) => ({ key, value: typeof v === 'string' ? v : JSON.stringify(v) ?? 'null' }));
93
+ }
94
+
95
+ /** `2026-03-01T12:00:00.000Z` → `12:00:00` — the wall-position the debugger's stream reads by.
96
+ * Pure string slicing, so it neither reads a clock nor depends on the machine's zone. */
97
+ export function clockLabel(iso: unknown): string {
98
+ const s = String(iso ?? '');
99
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(s) ? s.slice(11, 19) : '—';
100
+ }
101
+
102
+ const APP_SHELL = `<!doctype html>
103
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
104
+ <base href="/"><title>Segment Source Debugger (twin)</title><link rel="stylesheet" href="assets/styles.css"></head>
105
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
106
+
107
+ let clientBundle: Promise<string> | null = null;
108
+ /** Build the React/TSX debugger client to browser JS (Bun bundles TSX); cached per process. */
109
+ export function buildSegmentMirrorClient(): Promise<string> {
110
+ if (!clientBundle) {
111
+ clientBundle = bundleClient(CLIENT_ENTRY())
112
+ .catch((error) => { clientBundle = null; throw error; });
113
+ }
114
+ return clientBundle;
115
+ }
116
+
117
+ /** Serve the Source Debugger mirror (React app) + the twin's own fetch adapter behind it. */
118
+ export async function createSegmentMirrorServer(options: { root?: string; port?: number } = {}): Promise<{ port: number; stop: () => void }> {
119
+ const twin = createSegmentTwinFetch(options);
120
+ const server = await serveHttp({
121
+ // LOOPBACK-SPECIFIC bind: with the default wildcard hostname, `port: 0` can be handed a port
122
+ // some long-running app already LISTENS on at 127.0.0.1, and that more specific listener then
123
+ // shadows this server for every 127.0.0.1 fetch — the verify would talk to a stranger.
124
+ hostname: '127.0.0.1',
125
+ port: options.port ?? 0,
126
+ idleTimeout: 60,
127
+ async fetch(request) {
128
+ const url = new URL(request.url);
129
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
130
+ try { return new Response(await buildSegmentMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
131
+ catch (error) { return new Response(String(error), { status: 500 }); }
132
+ }
133
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
134
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
135
+ }
136
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
137
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
138
+ }
139
+ // Everything else → the twin's OWN FETCH ADAPTER (composition, R2). The client reads
140
+ // `/twin/store/events` and `/twin/store/dropped` on this same origin, and the adapter is the
141
+ // same closure `createSegmentTwinServer` serves — so there is exactly ONE serving code path
142
+ // and the mirror port cannot drift from the API port.
143
+ return twin(request);
144
+ },
145
+ });
146
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
147
+ }
148
+
149
+ /** The app-shell HTML (pure, for tests). The debugger itself is the React client. */
150
+ export function segmentMirrorHtml(): string {
151
+ return APP_SHELL;
152
+ }
153
+
154
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
155
+ export function segmentMirrorStyles(): Promise<string> {
156
+ return readFile(CLIENT_CSS(), 'utf8');
157
+ }