Skip to content

Commit 703e5b0

Browse files
feat(audience): typed track() surface for v1 event catalogue
Adds the v1 typed event surface to @imtbl/audience: 11 events (sign_up, sign_in, wishlist_add, wishlist_remove, purchase, game_launch, progression, resource, email_acquired, game_page_viewed, link_clicked), each with a property interface and compile-time enforcement on the track() call site. Uses a single generic track<E extends string>(event, ...args) signature backed by a PropsFor<E> conditional type. Traditional overloads fall through to the string catch-all when the properties object is incomplete (TypeScript picks the more-specific overload's first arg match, sees the second arg is not assignable, then silently picks the less-specific overload that accepts any object). The generic approach does not fall through and produces a real error for missing, wrong-type, and extra properties. A variadic-tuple conditional ({} extends PropsFor<E>) makes the properties argument required for events that have required fields and optional for events where every field is optional. Extended events (email_acquired, game_page_viewed, link_clicked) carry over from immutable/play#5151 with two normalisations: - isLoggedIn is dropped from all three shapes. The SDK already knows logged-in state via identify() + consent level; pipeline consumers should derive is_logged_in from userId IS NOT NULL on the events table rather than accept a duplicated property from every call site. - source is a free string. Play keeps its AudienceSource enum locally and passes its values into the SDK as strings. Runtime is unchanged modulo one type cast in the enqueue call (widening PropsFor<E> to Record<string, unknown>). Bundle size unchanged. The track() method body's runtime logic is identical to baseline. Type safety is asserted by a new sdk.test-d.ts file that uses @ts-expect-error to prove negative cases fail typecheck — missing required properties, extra properties, wrong value types, wrong enum values, and zero-argument calls on required-props events. The file is included in tsc but skipped by Jest (its .test-d.ts suffix does not match the default test pattern). Runtime coverage: five new it blocks in sdk.test.ts exercise sign_up, game_launch, progression, resource, and wishlist_add end-to-end through the queue flush path. 54 tests passing (up from 49). Independent of the feat/audience-web-sdk-demo branch and the Phase 0 audience-foundations work. No shared files; merges in any order. Spec: docs/superpowers/specs/2026-04-13-websdk-typed-events-design.md Plan: docs/superpowers/plans/2026-04-13-websdk-typed-events.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fc9f900 commit 703e5b0

5 files changed

Lines changed: 412 additions & 3 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
export const AudienceEvents = {
2+
SIGN_UP: 'sign_up',
3+
SIGN_IN: 'sign_in',
4+
WISHLIST_ADD: 'wishlist_add',
5+
WISHLIST_REMOVE: 'wishlist_remove',
6+
PURCHASE: 'purchase',
7+
8+
GAME_LAUNCH: 'game_launch',
9+
PROGRESSION: 'progression',
10+
RESOURCE: 'resource',
11+
12+
EMAIL_ACQUIRED: 'email_acquired',
13+
GAME_PAGE_VIEWED: 'game_page_viewed',
14+
LINK_CLICKED: 'link_clicked',
15+
} as const;
16+
17+
export interface SignUpProperties {
18+
/** Optional. */
19+
method?: string;
20+
}
21+
22+
export interface SignInProperties {
23+
/** Optional. */
24+
method?: string;
25+
}
26+
27+
export interface WishlistAddProperties {
28+
/** Required. */
29+
gameId: string;
30+
/** Optional. */
31+
source?: string;
32+
/** Optional. */
33+
platform?: string;
34+
}
35+
36+
export interface WishlistRemoveProperties {
37+
/** Required. */
38+
gameId: string;
39+
}
40+
41+
export interface PurchaseProperties {
42+
/** Required. */
43+
currency: string;
44+
/** Required. */
45+
value: number;
46+
/** Optional. */
47+
itemId?: string;
48+
/** Optional. */
49+
itemName?: string;
50+
/** Optional. */
51+
quantity?: number;
52+
/** Optional. */
53+
transactionId?: string;
54+
}
55+
56+
export interface GameLaunchProperties {
57+
/** Optional. */
58+
platform?: string;
59+
/** Optional. */
60+
version?: string;
61+
/** Optional. */
62+
buildId?: string;
63+
}
64+
65+
export type ProgressionStatus = 'start' | 'complete' | 'fail';
66+
67+
export interface ProgressionProperties {
68+
/** Required. */
69+
status: ProgressionStatus;
70+
/** Optional. */
71+
world?: string;
72+
/** Optional. */
73+
level?: string;
74+
/** Optional. */
75+
stage?: string;
76+
/** Optional. */
77+
score?: number;
78+
/** Optional. */
79+
durationSec?: number;
80+
}
81+
82+
export type ResourceFlow = 'sink' | 'source';
83+
84+
export interface ResourceProperties {
85+
/** Required. */
86+
flow: ResourceFlow;
87+
/** Required. */
88+
currency: string;
89+
/** Required. */
90+
amount: number;
91+
/** Optional. */
92+
itemType?: string;
93+
/** Optional. */
94+
itemId?: string;
95+
}
96+
97+
export interface EmailAcquiredProperties {
98+
/** Optional. */
99+
source?: string;
100+
}
101+
102+
export interface GamePageViewedProperties {
103+
/** Required. */
104+
gameId: string;
105+
/** Optional. */
106+
gameName?: string;
107+
/** Optional. */
108+
slug?: string;
109+
}
110+
111+
export interface LinkClickedProperties {
112+
/** Required. */
113+
url: string;
114+
/** Optional. */
115+
label?: string;
116+
/** Optional. */
117+
source?: string;
118+
/** Optional. */
119+
gameId?: string;
120+
}
121+
122+
interface EventPropsMap {
123+
sign_up: SignUpProperties;
124+
sign_in: SignInProperties;
125+
wishlist_add: WishlistAddProperties;
126+
wishlist_remove: WishlistRemoveProperties;
127+
purchase: PurchaseProperties;
128+
game_launch: GameLaunchProperties;
129+
progression: ProgressionProperties;
130+
resource: ResourceProperties;
131+
email_acquired: EmailAcquiredProperties;
132+
game_page_viewed: GamePageViewedProperties;
133+
link_clicked: LinkClickedProperties;
134+
}
135+
136+
/**
137+
* Event name → property type. Falls back to `Record<string, unknown>` for
138+
* unknown names. Used by `sdk.track()` to type-check property shapes at the
139+
* call site. Invariants pinned by `sdk.test-d.ts` — run it before simplifying.
140+
* For example, `sdk.track('purchase', { currency: 'USD' })` fails to compile
141+
* because `PurchaseProperties.value` is missing.
142+
*
143+
* If you change `PropsFor` (e.g. remove `Record<string, unknown>`), run
144+
* `pnpm typecheck` after any edit. `sdk.test-d.ts` has deliberately-wrong
145+
* `sdk.track()` calls that should fail to compile. If your edit makes any
146+
* of them start compiling, the build breaks.
147+
*/
148+
export type PropsFor<E extends string> =
149+
E extends keyof EventPropsMap ? EventPropsMap[E] : Record<string, unknown>;

packages/audience/sdk/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
export { Audience } from './sdk';
2+
export { AudienceEvents } from './events';
23
export { IdentityType } from '@imtbl/audience-core';
34
export type { AudienceConfig } from './types';
5+
export type {
6+
EmailAcquiredProperties,
7+
GameLaunchProperties,
8+
GamePageViewedProperties,
9+
LinkClickedProperties,
10+
ProgressionProperties,
11+
ProgressionStatus,
12+
PurchaseProperties,
13+
ResourceFlow,
14+
ResourceProperties,
15+
SignInProperties,
16+
SignUpProperties,
17+
WishlistAddProperties,
18+
WishlistRemoveProperties,
19+
} from './events';
420
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Audience, AudienceEvents } from './index';
2+
3+
declare const sdk: Audience;
4+
5+
sdk.track('sign_up');
6+
sdk.track('sign_up', { method: 'email' });
7+
sdk.track('sign_in');
8+
sdk.track('sign_in', { method: 'passport' });
9+
sdk.track('wishlist_add', { gameId: 'abc' });
10+
sdk.track('wishlist_add', { gameId: 'abc', source: 'game_page', platform: 'steam' });
11+
sdk.track('wishlist_remove', { gameId: 'abc' });
12+
sdk.track('purchase', { currency: 'USD', value: 9.99 });
13+
sdk.track('purchase', {
14+
currency: 'USD',
15+
value: 9.99,
16+
itemId: 'sku_1',
17+
itemName: 'Gold Pack',
18+
quantity: 2,
19+
transactionId: 'txn_42',
20+
});
21+
sdk.track('game_launch');
22+
sdk.track('game_launch', { platform: 'webgl', version: '1.2.0', buildId: 'ci-42' });
23+
sdk.track('progression', { status: 'start' });
24+
sdk.track('progression', { status: 'complete', world: 'tutorial' });
25+
sdk.track('progression', {
26+
status: 'fail',
27+
world: 'forest',
28+
level: '5',
29+
stage: 'boss',
30+
score: 420,
31+
durationSec: 87,
32+
});
33+
sdk.track('resource', { flow: 'sink', currency: 'gold', amount: 50 });
34+
sdk.track('resource', {
35+
flow: 'source',
36+
currency: 'USD',
37+
amount: 9.99,
38+
itemType: 'iap',
39+
itemId: 'sku_1',
40+
});
41+
sdk.track('email_acquired');
42+
sdk.track('email_acquired', { source: 'linked_account_steam' });
43+
sdk.track('game_page_viewed', { gameId: 'abc' });
44+
sdk.track('game_page_viewed', { gameId: 'abc', gameName: 'Devilfish', slug: 'devilfish' });
45+
sdk.track('link_clicked', { url: 'https://example.com' });
46+
sdk.track('link_clicked', {
47+
url: 'https://example.com',
48+
label: 'Play Now',
49+
source: 'game_page',
50+
gameId: 'abc',
51+
});
52+
53+
sdk.track(AudienceEvents.SIGN_UP, { method: 'email' });
54+
sdk.track(AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99 });
55+
sdk.track(AudienceEvents.PROGRESSION, { status: 'complete' });
56+
57+
// @ts-expect-error — missing required 'value'
58+
sdk.track('purchase', { currency: 'USD' });
59+
60+
// @ts-expect-error — missing required 'gameId'
61+
sdk.track('wishlist_add', {});
62+
63+
// @ts-expect-error — missing required 'status'
64+
sdk.track('progression', { world: 'tutorial' });
65+
66+
// @ts-expect-error — missing required 'flow', 'currency', 'amount'
67+
sdk.track('resource', { itemType: 'iap' });
68+
69+
// @ts-expect-error — missing required 'url'
70+
sdk.track('link_clicked', { label: 'Play Now' });
71+
72+
// @ts-expect-error — missing required 'gameId'
73+
sdk.track('game_page_viewed', {});
74+
75+
// @ts-expect-error — purchase requires properties
76+
sdk.track('purchase');
77+
78+
// @ts-expect-error — wishlist_add requires properties
79+
sdk.track('wishlist_add');
80+
81+
// @ts-expect-error — wishlist_remove requires properties
82+
sdk.track('wishlist_remove');
83+
84+
// @ts-expect-error — progression requires properties
85+
sdk.track('progression');
86+
87+
// @ts-expect-error — resource requires properties
88+
sdk.track('resource');
89+
90+
// @ts-expect-error — game_page_viewed requires properties
91+
sdk.track('game_page_viewed');
92+
93+
// @ts-expect-error — link_clicked requires properties
94+
sdk.track('link_clicked');
95+
96+
// @ts-expect-error — unknown property 'currenyc'
97+
sdk.track('purchase', { currenyc: 'USD', value: 9.99 });
98+
99+
// @ts-expect-error — unknown property 'extra'
100+
sdk.track('purchase', { currency: 'USD', value: 9.99, extra: 1 });
101+
102+
// @ts-expect-error — unknown property 'isLoggedIn'
103+
sdk.track('link_clicked', { url: 'x', isLoggedIn: true });
104+
105+
// @ts-expect-error — 'value' must be number
106+
sdk.track('purchase', { currency: 'USD', value: '9.99' });
107+
108+
// @ts-expect-error — 'status' must be a ProgressionStatus
109+
sdk.track('progression', { status: 'done' });
110+
111+
// @ts-expect-error — 'flow' must be a ResourceFlow
112+
sdk.track('resource', { flow: 'both', currency: 'gold', amount: 1 });
113+
114+
sdk.track('beta_key_redeemed', { source: 'influencer' });
115+
sdk.track('discord_joined');
116+
sdk.track('trailer_watched', { duration: 45, platform: 'youtube' });
117+
118+
declare const dynamicName: string;
119+
sdk.track(dynamicName, { anything: 'goes' });
120+
sdk.track(dynamicName);

0 commit comments

Comments
 (0)