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