@holdyapp/sdk 0.2.0 → 1.0.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 (75) hide show
  1. package/README.md +20 -147
  2. package/dist/auto-capture.d.ts +12 -16
  3. package/dist/auto-capture.d.ts.map +1 -0
  4. package/dist/browser.d.ts +23 -0
  5. package/dist/browser.d.ts.map +1 -0
  6. package/dist/core.d.ts +42 -0
  7. package/dist/core.d.ts.map +1 -0
  8. package/dist/holdy.js +2 -0
  9. package/dist/holdy.js.map +7 -0
  10. package/dist/index.cjs.js +422 -0
  11. package/dist/index.cjs.js.map +7 -0
  12. package/dist/index.d.ts +5 -46
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.mjs +399 -0
  15. package/dist/index.mjs.map +7 -0
  16. package/dist/server.cjs.js +171 -0
  17. package/dist/server.cjs.js.map +7 -0
  18. package/dist/server.d.ts +15 -0
  19. package/dist/server.d.ts.map +1 -0
  20. package/dist/server.mjs +148 -0
  21. package/dist/server.mjs.map +7 -0
  22. package/dist/types.d.ts +41 -109
  23. package/dist/types.d.ts.map +1 -0
  24. package/dist/web-context.d.ts +51 -0
  25. package/dist/web-context.d.ts.map +1 -0
  26. package/dist/web.d.ts +41 -0
  27. package/dist/web.d.ts.map +1 -0
  28. package/dist/web.mjs +389 -0
  29. package/dist/web.mjs.map +7 -0
  30. package/package.json +25 -21
  31. package/src/auto-capture.ts +51 -78
  32. package/src/browser.ts +55 -0
  33. package/src/core.test.ts +147 -0
  34. package/src/core.ts +172 -0
  35. package/src/index.ts +4 -427
  36. package/src/server.ts +19 -0
  37. package/src/types.ts +38 -162
  38. package/src/web-context.test.ts +71 -0
  39. package/src/web-context.ts +227 -0
  40. package/src/web.ts +108 -0
  41. package/LICENSE +0 -21
  42. package/dist/anchor.d.ts +0 -8
  43. package/dist/holdy.cjs.js +0 -255
  44. package/dist/holdy.cjs.js.map +0 -7
  45. package/dist/holdy.esm.js +0 -255
  46. package/dist/holdy.esm.js.map +0 -7
  47. package/dist/holdy.iife.js +0 -255
  48. package/dist/holdy.iife.js.map +0 -7
  49. package/dist/outcome.d.ts +0 -19
  50. package/dist/queue.d.ts +0 -54
  51. package/dist/render/kinds/banner.d.ts +0 -3
  52. package/dist/render/kinds/checklist.d.ts +0 -19
  53. package/dist/render/kinds/cta.d.ts +0 -8
  54. package/dist/render/kinds/modal.d.ts +0 -16
  55. package/dist/render/kinds/shared.d.ts +0 -16
  56. package/dist/render/kinds/spotlight.d.ts +0 -17
  57. package/dist/render/kinds/tooltip.d.ts +0 -3
  58. package/dist/render/root.d.ts +0 -3
  59. package/dist/render/styles.d.ts +0 -5
  60. package/dist/store.d.ts +0 -36
  61. package/dist/transport.d.ts +0 -59
  62. package/src/anchor.ts +0 -72
  63. package/src/outcome.ts +0 -51
  64. package/src/queue.ts +0 -296
  65. package/src/render/kinds/banner.ts +0 -52
  66. package/src/render/kinds/checklist.ts +0 -175
  67. package/src/render/kinds/cta.ts +0 -53
  68. package/src/render/kinds/modal.ts +0 -129
  69. package/src/render/kinds/shared.ts +0 -67
  70. package/src/render/kinds/spotlight.ts +0 -176
  71. package/src/render/kinds/tooltip.ts +0 -82
  72. package/src/render/root.ts +0 -69
  73. package/src/render/styles.ts +0 -280
  74. package/src/store.ts +0 -106
  75. package/src/transport.ts +0 -182
@@ -1,129 +1,102 @@
1
1
  /**
2
- * Auto-capture · Stage 28 G_auto.
2
+ * Auto-capture de navegação · @holdyapp/sdk/web.
3
3
  *
4
- * Quando HoldyConfig.autoCapture=true, instalamos hooks globais que
5
- * emitem `session.activity` automaticamente:
4
+ * Emite pageview no carregamento e a cada mudança de rota, inclusive nas que
5
+ * não recarregam a página (SPA). Sem isto, instrumentar exige que o cliente
6
+ * chame `page()` em cada tela — e o primeiro dado só aparece depois de um
7
+ * trabalho que ele ainda não fez.
6
8
  *
7
- * · initial_load · 1x ao init
8
- * · route_change · cada history.pushState/replaceState/popstate
9
- * · visibility_change · página voltou a ficar visível
10
- *
11
- * Tudo client-side · não precisa que o customer declare nada. Permite que
12
- * o widget de install do Holdy mostre "✓ conectado" segundos após o user
13
- * carregar a página.
14
- *
15
- * Idempotente · re-rodar `installAutoCapture` é no-op via guard global.
16
- * Cleanup ao destroy via array de unsubscribers retornado.
9
+ * Idempotente: reinstalar é no-op (guard global). O retorno desfaz o patch de
10
+ * `history`, então `destroy()` devolve o browser como estava.
17
11
  */
18
12
 
19
- export interface AutoCaptureEmit {
20
- (path: string, title: string | undefined, referrer: string | undefined, kind: 'initial_load' | 'route_change' | 'visibility_change'): void
21
- }
13
+ const INSTALLED_FLAG = '__holdy_autocapture__'
22
14
 
23
- const INSTALLED_FLAG = '__holdy_autocapture_installed__'
15
+ /** pushState seguido de replaceState é uma mudança lógica só. */
24
16
  const ROUTE_DEBOUNCE_MS = 50
25
17
 
18
+ export type NavigationKind = 'initial_load' | 'route_change'
19
+
20
+ export interface AutoCaptureOptions {
21
+ onNavigate: (kind: NavigationKind) => void
22
+ }
23
+
26
24
  interface GlobalWithFlag {
27
25
  [INSTALLED_FLAG]?: boolean
28
- history?: History
29
26
  }
30
27
 
31
- export function installAutoCapture(emit: AutoCaptureEmit): () => void {
28
+ export function installAutoCapture(opts: AutoCaptureOptions): () => void {
32
29
  if (typeof window === 'undefined' || typeof history === 'undefined') {
33
30
  return () => undefined
34
31
  }
32
+
35
33
  const w = window as unknown as GlobalWithFlag
36
- if (w[INSTALLED_FLAG]) {
37
- return () => undefined
38
- }
34
+ if (w[INSTALLED_FLAG]) return () => undefined
39
35
  w[INSTALLED_FLAG] = true
40
36
 
41
37
  const cleanups: Array<() => void> = []
42
38
  let lastPath = currentPath()
43
- let pendingRouteEmit: ReturnType<typeof setTimeout> | null = null
39
+ let pending: ReturnType<typeof setTimeout> | null = null
44
40
 
45
- function safeEmit(kind: 'initial_load' | 'route_change' | 'visibility_change'): void {
41
+ function emit(kind: NavigationKind): void {
46
42
  try {
47
- const path = currentPath()
48
- const title = typeof document !== 'undefined' ? document.title : undefined
49
- const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined
50
- emit(path, title, referrer, kind)
43
+ opts.onNavigate(kind)
51
44
  } catch {
52
- // never throw from auto-capture · não pode quebrar a app do cliente
45
+ // Telemetria nunca derruba a app do cliente.
53
46
  }
54
47
  }
55
48
 
56
49
  function scheduleRouteEmit(): void {
57
- // Debounce: pushState seguido de replaceState é 1 mudança lógica.
58
- if (pendingRouteEmit) clearTimeout(pendingRouteEmit)
59
- pendingRouteEmit = setTimeout(() => {
50
+ if (pending) clearTimeout(pending)
51
+ pending = setTimeout(() => {
52
+ pending = null
60
53
  const path = currentPath()
61
- if (path !== lastPath) {
62
- lastPath = path
63
- safeEmit('route_change')
64
- }
65
- pendingRouteEmit = null
54
+ // Só conta como pageview se a rota mudou de fato — replaceState de
55
+ // estado interno (filtro, scroll) não é navegação.
56
+ if (path === lastPath) return
57
+ lastPath = path
58
+ emit('route_change')
66
59
  }, ROUTE_DEBOUNCE_MS)
67
60
  }
68
61
 
69
- // 1) Initial load
70
- safeEmit('initial_load')
62
+ emit('initial_load')
63
+
64
+ const originalPush = history.pushState
65
+ const originalReplace = history.replaceState
71
66
 
72
- // 2) Monkeypatch history.pushState/replaceState (SPA SDR navigation)
73
- const origPush = history.pushState
74
- const origReplace = history.replaceState
75
67
  history.pushState = function patchedPush(...args: Parameters<History['pushState']>) {
76
- const result = origPush.apply(this, args)
68
+ const result = originalPush.apply(this, args)
77
69
  scheduleRouteEmit()
78
70
  return result
79
71
  }
80
72
  history.replaceState = function patchedReplace(...args: Parameters<History['replaceState']>) {
81
- const result = origReplace.apply(this, args)
73
+ const result = originalReplace.apply(this, args)
82
74
  scheduleRouteEmit()
83
75
  return result
84
76
  }
85
77
  cleanups.push(() => {
86
- history.pushState = origPush
87
- history.replaceState = origReplace
78
+ history.pushState = originalPush
79
+ history.replaceState = originalReplace
88
80
  })
89
81
 
90
- // 3) popstate · botão voltar/avançar do browser
91
- const popHandler = (): void => scheduleRouteEmit()
92
- window.addEventListener('popstate', popHandler)
93
- cleanups.push(() => window.removeEventListener('popstate', popHandler))
94
-
95
- // 4) hashchange · single-page apps que usam hash routing (router antigo)
96
- const hashHandler = (): void => scheduleRouteEmit()
97
- window.addEventListener('hashchange', hashHandler)
98
- cleanups.push(() => window.removeEventListener('hashchange', hashHandler))
99
-
100
- // 5) visibility_change · tab voltou a ficar visível depois de >5min
101
- let hiddenAt: number | null = null
102
- const visHandler = (): void => {
103
- if (typeof document === 'undefined') return
104
- if (document.visibilityState === 'hidden') {
105
- hiddenAt = Date.now()
106
- } else if (document.visibilityState === 'visible' && hiddenAt) {
107
- const elapsed = Date.now() - hiddenAt
108
- hiddenAt = null
109
- // Re-emit só se ficou hidden >5min · evita flood quando user troca aba rapidinho
110
- if (elapsed > 5 * 60_000) safeEmit('visibility_change')
111
- }
112
- }
113
- if (typeof document !== 'undefined') {
114
- document.addEventListener('visibilitychange', visHandler)
115
- cleanups.push(() => document.removeEventListener('visibilitychange', visHandler))
116
- }
82
+ // Voltar/avançar do browser e routers baseados em hash.
83
+ const onPop = (): void => scheduleRouteEmit()
84
+ window.addEventListener('popstate', onPop)
85
+ cleanups.push(() => window.removeEventListener('popstate', onPop))
86
+
87
+ const onHash = (): void => scheduleRouteEmit()
88
+ window.addEventListener('hashchange', onHash)
89
+ cleanups.push(() => window.removeEventListener('hashchange', onHash))
117
90
 
118
- // Final cleanup function · usado pelo destroy()
119
91
  return () => {
120
- if (pendingRouteEmit) clearTimeout(pendingRouteEmit)
121
- for (const c of cleanups) c()
92
+ if (pending) clearTimeout(pending)
93
+ for (const cleanup of cleanups) cleanup()
122
94
  w[INSTALLED_FLAG] = false
123
95
  }
124
96
  }
125
97
 
98
+ /** Rota comparável · path + query, sem hash de âncora. */
126
99
  function currentPath(): string {
127
- if (typeof window === 'undefined' || !window.location) return '/'
128
- return window.location.pathname + window.location.search + window.location.hash
100
+ if (typeof location === 'undefined') return '/'
101
+ return `${location.pathname}${location.search}${location.hash}`
129
102
  }
package/src/browser.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Entrada do bundle IIFE · global `Holdy` pra instalação por `<script>`.
3
+ *
4
+ * Existe pro cliente que não tem bundler (ou não quer mexer no build) poder
5
+ * instrumentar colando duas linhas no HTML. A API é a mesma de `@holdyapp/sdk/web`
6
+ * em forma de funções soltas, porque `Holdy.track(...)` é o que se espera de um
7
+ * global.
8
+ *
9
+ * Chamar antes de `init` não lança: telemetria não derruba a página do cliente.
10
+ */
11
+
12
+ import { HoldyWeb, type HoldyWebOptions } from './web'
13
+ import type { Properties } from './types'
14
+
15
+ let instance: HoldyWeb | null = null
16
+
17
+ export function init(opts: HoldyWebOptions): HoldyWeb {
18
+ // Idempotente: script duplicado na página não duplica evento.
19
+ if (!instance) instance = new HoldyWeb(opts)
20
+ return instance
21
+ }
22
+
23
+ export function track(name: string, properties?: Properties): void {
24
+ instance?.track(name, properties)
25
+ }
26
+
27
+ export function identify(userId: string, traits?: Properties): void {
28
+ instance?.identify(userId, traits)
29
+ }
30
+
31
+ export function group(accountKey: string, traits?: Properties): void {
32
+ instance?.group(accountKey, traits)
33
+ }
34
+
35
+ export function page(name?: string, properties?: Properties): void {
36
+ instance?.page(name, properties)
37
+ }
38
+
39
+ export function reset(): void {
40
+ instance?.reset()
41
+ }
42
+
43
+ export async function flush(): Promise<void> {
44
+ await instance?.flush()
45
+ }
46
+
47
+ export async function shutdown(): Promise<void> {
48
+ await instance?.shutdown()
49
+ instance = null
50
+ }
51
+
52
+ /** Instância corrente · útil pra depurar no console do browser. */
53
+ export function getInstance(): HoldyWeb | null {
54
+ return instance
55
+ }
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { HoldyCore } from './core'
3
+
4
+ interface SentBatch {
5
+ url: string
6
+ auth: string | undefined
7
+ events: Array<Record<string, unknown>>
8
+ }
9
+
10
+ function mockFetch(status = 202) {
11
+ const sent: SentBatch[] = []
12
+ const fn = vi.fn(async (url: unknown, init?: RequestInit) => {
13
+ sent.push({
14
+ url: String(url),
15
+ auth: (init?.headers as Record<string, string> | undefined)?.Authorization,
16
+ events: (JSON.parse(String(init?.body)) as { events: Array<Record<string, unknown>> }).events,
17
+ })
18
+ return { ok: status < 400, status } as Response
19
+ })
20
+ vi.stubGlobal('fetch', fn)
21
+ return { sent, fn }
22
+ }
23
+
24
+ function makeClient(opts: Partial<ConstructorParameters<typeof HoldyCore>[1]> = {}) {
25
+ return new HoldyCore('sdk_server', {
26
+ apiKey: 'hol_test_key',
27
+ baseUrl: 'https://example.test/api/v1',
28
+ flushIntervalMs: 0,
29
+ flushAt: 100,
30
+ ...opts,
31
+ })
32
+ }
33
+
34
+ describe('HoldyCore', () => {
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals()
37
+ })
38
+
39
+ it('track enfileira envelope canônico e flush envia pra /events/bulk com Bearer', async () => {
40
+ const { sent } = mockFetch()
41
+ const holdy = makeClient()
42
+ holdy.track('invite_sent', { plan: 'pro' })
43
+ expect(holdy.pending).toBe(1)
44
+
45
+ await holdy.flush()
46
+ expect(sent).toHaveLength(1)
47
+ expect(sent[0].url).toBe('https://example.test/api/v1/events/bulk')
48
+ expect(sent[0].auth).toBe('Bearer hol_test_key')
49
+ expect(sent[0].events[0]).toMatchObject({
50
+ event: 'invite_sent',
51
+ source: 'sdk_server',
52
+ properties: { plan: 'pro' },
53
+ })
54
+ expect(holdy.pending).toBe(0)
55
+ })
56
+
57
+ it('identify emite $identify e persiste user_id nos próximos events', async () => {
58
+ const { sent } = mockFetch()
59
+ const holdy = makeClient()
60
+ holdy.identify('user_42', { role: 'admin' })
61
+ holdy.track('pageview')
62
+ await holdy.flush()
63
+
64
+ expect(sent[0].events[0]).toMatchObject({
65
+ event: '$identify',
66
+ user_id: 'user_42',
67
+ properties: { role: 'admin' },
68
+ })
69
+ expect(sent[0].events[1]).toMatchObject({ event: 'pageview', user_id: 'user_42' })
70
+ })
71
+
72
+ it('group emite $group e associa context.account_external_id aos próximos', async () => {
73
+ const { sent } = mockFetch()
74
+ const holdy = makeClient()
75
+ holdy.group('acme-inc', { plan: 'scale' })
76
+ holdy.track('feature_used')
77
+ await holdy.flush()
78
+
79
+ expect(sent[0].events[0]).toMatchObject({
80
+ event: '$group',
81
+ context: { account_external_id: 'acme-inc' },
82
+ properties: { plan: 'scale' },
83
+ })
84
+ expect(sent[0].events[1]).toMatchObject({
85
+ event: 'feature_used',
86
+ context: { account_external_id: 'acme-inc' },
87
+ })
88
+ // account_id (uuid Holdy) nunca sai da porta — Data layer resolve
89
+ expect('account_id' in sent[0].events[1]).toBe(false)
90
+ })
91
+
92
+ it('overrides stateless (server): userId/accountKey por chamada', async () => {
93
+ const { sent } = mockFetch()
94
+ const holdy = makeClient()
95
+ holdy.track('sync_done', {}, { userId: 'u9', accountKey: 'org-77' })
96
+ await holdy.flush()
97
+
98
+ expect(sent[0].events[0]).toMatchObject({
99
+ user_id: 'u9',
100
+ context: { account_external_id: 'org-77' },
101
+ })
102
+ })
103
+
104
+ it('flushAt dispara flush automático', async () => {
105
+ const { sent } = mockFetch()
106
+ const holdy = makeClient({ flushAt: 2 })
107
+ holdy.track('a')
108
+ expect(sent).toHaveLength(0)
109
+ holdy.track('b')
110
+ await vi.waitFor(() => expect(sent).toHaveLength(1))
111
+ expect(sent[0].events).toHaveLength(2)
112
+ })
113
+
114
+ it('5xx re-enfileira (retry dedupe-safe) · 4xx dropa (evita loop)', async () => {
115
+ const { sent } = mockFetch(500)
116
+ const holdy = makeClient()
117
+ holdy.track('x')
118
+ await holdy.flush()
119
+ expect(holdy.pending).toBe(1)
120
+ expect(sent).toHaveLength(1)
121
+
122
+ vi.unstubAllGlobals()
123
+ mockFetch(400)
124
+ await holdy.flush()
125
+ expect(holdy.pending).toBe(0)
126
+ })
127
+
128
+ it('network error nunca lança — re-enfileira', async () => {
129
+ vi.stubGlobal('fetch', vi.fn(async () => {
130
+ throw new Error('ECONNREFUSED')
131
+ }))
132
+ const holdy = makeClient()
133
+ holdy.track('x')
134
+ await expect(holdy.flush()).resolves.toBeUndefined()
135
+ expect(holdy.pending).toBe(1)
136
+ })
137
+
138
+ it('batches > 500 são fatiados', async () => {
139
+ const { sent } = mockFetch()
140
+ const holdy = makeClient({ flushAt: 10000 })
141
+ for (let i = 0; i < 750; i++) holdy.track(`e${i}`)
142
+ await holdy.flush()
143
+ expect(sent).toHaveLength(2)
144
+ expect(sent[0].events).toHaveLength(500)
145
+ expect(sent[1].events).toHaveLength(250)
146
+ })
147
+ })
package/src/core.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Core do @holdyapp/sdk · fila + flush pra POST /api/v1/events/bulk.
3
+ *
4
+ * Retry de batch inteiro é seguro (partial accept + dedupe server-side).
5
+ * Fila é bounded: acima de MAX_QUEUE dropa os mais antigos (telemetria nunca
6
+ * derruba o app do cliente — nenhum método público lança).
7
+ */
8
+
9
+ import type { CanonicalEvent, HoldyOptions, Properties, TrackOverrides } from './types'
10
+
11
+ export const DEFAULT_BASE_URL = 'https://holdy.chat/api/v1'
12
+ const DEFAULT_FLUSH_AT = 20
13
+ const DEFAULT_FLUSH_INTERVAL_MS = 5000
14
+ const MAX_QUEUE = 1000
15
+ const BULK_LIMIT = 500
16
+
17
+ export class HoldyCore {
18
+ private readonly apiKey: string
19
+ private readonly baseUrl: string
20
+ private readonly flushAt: number
21
+ private readonly debug: boolean
22
+ private readonly source: 'sdk_web' | 'sdk_server'
23
+
24
+ private queue: CanonicalEvent[] = []
25
+ private timer: ReturnType<typeof setInterval> | null = null
26
+ private flushing: Promise<void> | null = null
27
+
28
+ protected userId: string | null = null
29
+ protected anonymousId: string | null = null
30
+ protected accountKey: string | null = null
31
+
32
+ constructor(source: 'sdk_web' | 'sdk_server', opts: HoldyOptions) {
33
+ this.source = source
34
+ this.apiKey = opts.apiKey
35
+ this.accountKey = opts.accountKey ?? null
36
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '')
37
+ this.flushAt = Math.max(1, opts.flushAt ?? DEFAULT_FLUSH_AT)
38
+ this.debug = opts.debug ?? false
39
+
40
+ const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS
41
+ if (intervalMs > 0) {
42
+ this.timer = setInterval(() => void this.flush(), intervalMs)
43
+ // Node: não segura o processo vivo só pelo timer do SDK.
44
+ if (typeof (this.timer as { unref?: () => void }).unref === 'function') {
45
+ ;(this.timer as unknown as { unref: () => void }).unref()
46
+ }
47
+ }
48
+ }
49
+
50
+ track(name: string, properties?: Properties, overrides?: TrackOverrides): void {
51
+ if (!name || typeof name !== 'string') return
52
+
53
+ const userId = overrides?.userId ?? this.userId ?? undefined
54
+ const accountKey = overrides?.accountKey ?? this.accountKey ?? undefined
55
+ const occurredAt = overrides?.occurredAt
56
+ ? new Date(overrides.occurredAt).toISOString()
57
+ : new Date().toISOString()
58
+
59
+ // Ordem: ambiente observado pelo SDK → context da chamada → carrier de
60
+ // account. O que o caller passou vence o inferido; o carrier vence tudo,
61
+ // porque é o que a Data layer usa pra resolver a conta.
62
+ const context: Properties = {
63
+ ...this.environmentContext(),
64
+ ...(overrides?.context ?? {}),
65
+ }
66
+ if (accountKey) context.account_external_id = accountKey
67
+
68
+ const event: CanonicalEvent = {
69
+ event: name,
70
+ occurred_at: occurredAt,
71
+ source: this.source,
72
+ ...(userId ? { user_id: userId } : {}),
73
+ ...(this.anonymousId ? { anonymous_id: this.anonymousId } : {}),
74
+ properties: properties ?? {},
75
+ ...(Object.keys(context).length > 0 ? { context } : {}),
76
+ }
77
+
78
+ this.queue.push(event)
79
+ if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE)
80
+ if (this.queue.length >= this.flushAt) void this.flush()
81
+ }
82
+
83
+ identify(userId: string, traits?: Properties): void {
84
+ if (!userId || typeof userId !== 'string') return
85
+ this.userId = userId
86
+ this.track('$identify', traits ?? {})
87
+ }
88
+
89
+ group(accountKey: string, traits?: Properties): void {
90
+ if (!accountKey || typeof accountKey !== 'string') return
91
+ this.accountKey = accountKey
92
+ this.track('$group', traits ?? {})
93
+ }
94
+
95
+ /** Drena a fila. Nunca lança — falha re-enfileira e loga em debug. */
96
+ async flush(): Promise<void> {
97
+ // Serializa flushes concorrentes (timer + flushAt + manual).
98
+ if (this.flushing) return this.flushing
99
+ if (this.queue.length === 0) return
100
+
101
+ this.flushing = this.drain().finally(() => {
102
+ this.flushing = null
103
+ })
104
+ return this.flushing
105
+ }
106
+
107
+ /** Flush final + para o timer. Chamar em shutdown do server / unload do web. */
108
+ async shutdown(): Promise<void> {
109
+ if (this.timer) {
110
+ clearInterval(this.timer)
111
+ this.timer = null
112
+ }
113
+ await this.flush()
114
+ }
115
+
116
+ private async drain(): Promise<void> {
117
+ while (this.queue.length > 0) {
118
+ const batch = this.queue.splice(0, BULK_LIMIT)
119
+ const ok = await this.send(batch)
120
+ if (!ok) {
121
+ // Devolve pra frente da fila e para — próximo flush re-tenta (dedupe-safe).
122
+ this.queue.unshift(...batch)
123
+ return
124
+ }
125
+ }
126
+ }
127
+
128
+ private async send(events: CanonicalEvent[]): Promise<boolean> {
129
+ try {
130
+ const res = await fetch(`${this.baseUrl}/events/bulk`, {
131
+ method: 'POST',
132
+ headers: {
133
+ Authorization: `Bearer ${this.apiKey}`,
134
+ 'Content-Type': 'application/json',
135
+ },
136
+ body: JSON.stringify({ events }),
137
+ ...(this.source === 'sdk_web' ? { keepalive: true } : {}),
138
+ })
139
+ if (!res.ok) {
140
+ // 4xx: batch estruturalmente inválido não re-tenta (evita loop infinito).
141
+ if (res.status >= 400 && res.status < 500) {
142
+ this.log(`bulk rejected ${res.status} · batch dropped`)
143
+ return true
144
+ }
145
+ this.log(`bulk failed ${res.status} · will retry`)
146
+ return false
147
+ }
148
+ return true
149
+ } catch (err) {
150
+ this.log(`bulk network error: ${(err as Error).message} · will retry`)
151
+ return false
152
+ }
153
+ }
154
+
155
+ private log(msg: string): void {
156
+ if (this.debug) console.warn(`[holdy] ${msg}`)
157
+ }
158
+
159
+ /**
160
+ * Ambiente que o SDK observa sozinho (url, referrer, browser, utm…).
161
+ * Vazio no core; `HoldyWeb` preenche. O server não infere ambiente — não
162
+ * existe "página atual" num worker.
163
+ */
164
+ protected environmentContext(): Properties {
165
+ return {}
166
+ }
167
+
168
+ /** Interno · exposto pra testes. */
169
+ get pending(): number {
170
+ return this.queue.length
171
+ }
172
+ }