@joryio/web-sdk 0.0.0-stage → 1.3.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 (76) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +54 -0
  3. package/README.md +497 -2
  4. package/dist/core/config.d.ts +101 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/device.d.ts +14 -0
  7. package/dist/core/device.d.ts.map +1 -0
  8. package/dist/core/ecommerce.d.ts +67 -0
  9. package/dist/core/ecommerce.d.ts.map +1 -0
  10. package/dist/core/identity.d.ts +36 -0
  11. package/dist/core/identity.d.ts.map +1 -0
  12. package/dist/core/inapp.d.ts +169 -0
  13. package/dist/core/inapp.d.ts.map +1 -0
  14. package/dist/core/network.d.ts +158 -0
  15. package/dist/core/network.d.ts.map +1 -0
  16. package/dist/core/outbox.d.ts +79 -0
  17. package/dist/core/outbox.d.ts.map +1 -0
  18. package/dist/core/pending-attributes.d.ts +43 -0
  19. package/dist/core/pending-attributes.d.ts.map +1 -0
  20. package/dist/core/push.d.ts +59 -0
  21. package/dist/core/push.d.ts.map +1 -0
  22. package/dist/core/queue.d.ts +107 -0
  23. package/dist/core/queue.d.ts.map +1 -0
  24. package/dist/core/sdk.d.ts +169 -0
  25. package/dist/core/sdk.d.ts.map +1 -0
  26. package/dist/core/session.d.ts +36 -0
  27. package/dist/core/session.d.ts.map +1 -0
  28. package/dist/core/storage.d.ts +17 -0
  29. package/dist/core/storage.d.ts.map +1 -0
  30. package/dist/core/user.d.ts +28 -0
  31. package/dist/core/user.d.ts.map +1 -0
  32. package/dist/core/utm.d.ts +39 -0
  33. package/dist/core/utm.d.ts.map +1 -0
  34. package/dist/core/values.d.ts +3 -0
  35. package/dist/core/values.d.ts.map +1 -0
  36. package/dist/core/write-retry.d.ts +66 -0
  37. package/dist/core/write-retry.d.ts.map +1 -0
  38. package/dist/index.d.mts +711 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/joryio-sw.js +6 -0
  42. package/dist/joryio.cjs.js +7265 -0
  43. package/dist/joryio.min.js +7 -0
  44. package/dist/joryio.min.js.map +1 -0
  45. package/dist/joryio.mjs +7254 -0
  46. package/dist/joryio.umd.js +7271 -0
  47. package/dist/utils/bucket-hash.d.ts +2 -0
  48. package/dist/utils/bucket-hash.d.ts.map +1 -0
  49. package/dist/utils/logger.d.ts +14 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/uuid.d.ts +3 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/package.json +90 -4
  54. package/src/core/config.ts +362 -0
  55. package/src/core/device.ts +50 -0
  56. package/src/core/ecommerce.ts +482 -0
  57. package/src/core/identity.ts +251 -0
  58. package/src/core/inapp.ts +2419 -0
  59. package/src/core/network.ts +1261 -0
  60. package/src/core/outbox.ts +707 -0
  61. package/src/core/pending-attributes.ts +350 -0
  62. package/src/core/push.ts +667 -0
  63. package/src/core/queue.ts +894 -0
  64. package/src/core/sdk.ts +3108 -0
  65. package/src/core/session.ts +311 -0
  66. package/src/core/storage.ts +157 -0
  67. package/src/core/user.ts +203 -0
  68. package/src/core/utm.ts +304 -0
  69. package/src/core/values.ts +85 -0
  70. package/src/core/write-retry.ts +136 -0
  71. package/src/index.ts +305 -0
  72. package/src/snippet/loader.js +134 -0
  73. package/src/utils/bucket-hash.ts +44 -0
  74. package/src/utils/logger.ts +58 -0
  75. package/src/utils/uuid.ts +29 -0
  76. package/src/worker/joryio-sw.js +218 -0
@@ -0,0 +1,350 @@
1
+ import type { AttributeOps } from './network';
2
+ import type { WriteOwner } from './write-retry';
3
+
4
+ /** One attribute write, in call order. */
5
+ export type AttributeOpKind = 'set' | 'unset' | 'increment' | 'append' | 'remove';
6
+
7
+ export interface AttributeEntry {
8
+ key: string;
9
+ kind: AttributeOpKind;
10
+ /** `set`: the value. `increment`: the summed delta. */
11
+ value?: any;
12
+ /** `append` / `remove`: every value, in call order, de-duplicated. */
13
+ values?: any[];
14
+ /** Carried by a request whose answer has not arrived: never merged into. */
15
+ inFlight?: boolean;
16
+ /**
17
+ * Whose write this is, captured at the call (contract items 28, 31, 38).
18
+ * A write of user A still waiting when the app calls identify('B') or
19
+ * reset() is sent with A's ids, not the new ones; one made while anonymous
20
+ * gets the user id at send time when that anonymous id was identified.
21
+ * Absent on entries stored by an earlier build: the current identity.
22
+ */
23
+ owner?: WriteOwner;
24
+ /** Counted failed attempts (contract item 40). */
25
+ attempts?: number;
26
+ }
27
+
28
+ /** One request's worth of writes, and the entries it carries (for the ack). */
29
+ export interface AttributeRequest {
30
+ ops: AttributeOps;
31
+ entries: AttributeEntry[];
32
+ /** Every entry of a request belongs to this one person. */
33
+ owner?: WriteOwner;
34
+ }
35
+
36
+ /**
37
+ * The ids an entry is SENT with (the SDK resolves an anonymous capture to the
38
+ * user that anonymous id became). Requests group entries by this, so a write
39
+ * made just before identify() rides with the ones made just after.
40
+ */
41
+ export type OwnerResolver = (owner: WriteOwner | undefined) => WriteOwner | undefined;
42
+
43
+ function sameOwner(a?: WriteOwner, b?: WriteOwner): boolean {
44
+ if (!a || !b) return !a && !b;
45
+ return a.anonymousId === b.anonymousId && (a.userId || null) === (b.userId || null);
46
+ }
47
+
48
+ /**
49
+ * Attribute writes the server has not acknowledged yet, as an ORDERED log.
50
+ *
51
+ * It used to be one map per operation (set / unset / append / remove), which
52
+ * lost writes in three ways (scan 3, contract item 31):
53
+ *
54
+ * - `addToArray('tags', 'a'); addToArray('tags', 'b')` kept ONE value per
55
+ * key, so only `b` was ever sent;
56
+ * - an `append` and a `remove` of the same key rode one request, and the API
57
+ * applies a single operator per key per request ($set > $inc > $addToSet >
58
+ * $pull > $unset), so the `remove` was silently skipped;
59
+ * - acknowledging by KEY cleared an append that was added while the request
60
+ * was in flight.
61
+ *
62
+ * Now every call appends an entry. Adjacent writes of the same kind on the
63
+ * same key merge (two appends become one append of both values, two
64
+ * increments one summed increment); an absolute write (`set` / `unset`)
65
+ * supersedes everything still waiting for that key. A request carries at most
66
+ * ONE entry per key - the oldest - so a later write to a key is always sent
67
+ * after the earlier one is acknowledged, and never shares a request with it.
68
+ * Acknowledgement removes the exact entries that were sent (by identity, so a
69
+ * value added meanwhile survives).
70
+ *
71
+ * ## Which operations are retried
72
+ *
73
+ * `set`, `unset`, `append` and `remove` are idempotent and retried until the
74
+ * server answers. `increment` is NOT: a retried `$inc` after an ambiguous
75
+ * failure (request delivered, response lost) counts twice, so an increment
76
+ * whose request failed ambiguously is reported and dropped instead (see
77
+ * `release`).
78
+ */
79
+ export class PendingAttributes {
80
+ private entries: AttributeEntry[] = [];
81
+
82
+ constructor(private readonly maxEntries = 500) {}
83
+
84
+ /** Record one write at the end of the log. */
85
+ add(kind: AttributeOpKind, key: string, value?: any, owner?: WriteOwner): void {
86
+ if (!key) return;
87
+ const who = owner ? { ...owner } : undefined;
88
+ if (kind === 'set' || kind === 'unset') {
89
+ // Absolute: whatever was still waiting for this key OF THIS PERSON is
90
+ // moot - except what is already on the wire, which is answered first.
91
+ this.entries = this.entries.filter((e) => e.key !== key || e.inFlight || !sameOwner(e.owner, who));
92
+ this.entries.push(kind === 'set' ? { key, kind, value, owner: who } : { key, kind, owner: who });
93
+ this.trim();
94
+ return;
95
+ }
96
+ const last = this.lastFor(key);
97
+ if (last && !last.inFlight && last.kind === kind && sameOwner(last.owner, who)) {
98
+ if (kind === 'increment') {
99
+ last.value = (Number(last.value) || 0) + Number(value);
100
+ } else {
101
+ for (const v of flatten(value)) if (!includesValue(last.values!, v)) last.values!.push(v);
102
+ }
103
+ return;
104
+ }
105
+ if (kind === 'increment') this.entries.push({ key, kind, value: Number(value), owner: who });
106
+ else this.entries.push({ key, kind, values: dedupe(flatten(value)), owner: who });
107
+ this.trim();
108
+ }
109
+
110
+ /**
111
+ * Record an op payload (the sections in their API order). Kept for callers
112
+ * and for state stored by an earlier SDK version.
113
+ */
114
+ mark(ops: AttributeOps, owner?: WriteOwner): void {
115
+ for (const [k, v] of Object.entries(ops.attributes || {})) this.add('set', k, v, owner);
116
+ for (const k of ops.unset || []) this.add('unset', k, undefined, owner);
117
+ for (const [k, v] of Object.entries(ops.increment || {})) {
118
+ if (typeof v === 'number' && Number.isFinite(v)) this.add('increment', k, v, owner);
119
+ }
120
+ for (const [k, v] of Object.entries(ops.append || {})) this.add('append', k, v, owner);
121
+ for (const [k, v] of Object.entries(ops.remove || {})) this.add('remove', k, v, owner);
122
+ }
123
+
124
+ /** True when there is nothing awaiting acknowledgement. */
125
+ isEmpty(): boolean {
126
+ return this.entries.length === 0;
127
+ }
128
+
129
+ /** True when there is something not yet on the wire. */
130
+ hasUnsent(): boolean {
131
+ return this.entries.some((e) => !e.inFlight);
132
+ }
133
+
134
+ /**
135
+ * The next request: the OLDEST entry of every key that has nothing in
136
+ * flight. Marks those entries in flight; answer with acknowledge / release /
137
+ * drop. Null when there is nothing that may be sent now.
138
+ */
139
+ nextRequest(resolve?: OwnerResolver): AttributeRequest | null {
140
+ return this.build(true, resolve);
141
+ }
142
+
143
+ /** The same request, without marking anything (unload, logout, storage). */
144
+ peekRequest(resolve?: OwnerResolver): AttributeRequest | null {
145
+ return this.build(false, resolve);
146
+ }
147
+
148
+ /** Legacy shape of peekRequest(): the op payload only. */
149
+ toOps(): AttributeOps | null {
150
+ return this.peekRequest()?.ops ?? null;
151
+ }
152
+
153
+ /** The server stored these: forget them (exactly these entries). */
154
+ acknowledge(sent: AttributeEntry[]): void {
155
+ const done = new Set(sent);
156
+ this.entries = this.entries.filter((e) => !done.has(e));
157
+ }
158
+
159
+ /**
160
+ * The request failed: its entries wait for the next attempt. Increments are
161
+ * dropped when the failure was ambiguous (`dropIncrements`), because a
162
+ * retried `$inc` may count twice; returns how many were dropped.
163
+ */
164
+ release(sent: AttributeEntry[], dropIncrements: boolean): number {
165
+ let dropped = 0;
166
+ const set = new Set(sent);
167
+ this.entries = this.entries.filter((e) => {
168
+ if (!set.has(e)) return true;
169
+ e.inFlight = false;
170
+ if (dropIncrements && e.kind === 'increment') {
171
+ dropped++;
172
+ return false;
173
+ }
174
+ return true;
175
+ });
176
+ return dropped;
177
+ }
178
+
179
+ /** The server refused these for good: drop them. */
180
+ drop(sent: AttributeEntry[]): void {
181
+ this.acknowledge(sent);
182
+ }
183
+
184
+ /** Increments handed off at page unload: never sent twice. */
185
+ dropIncrements(sent: AttributeEntry[]): void {
186
+ const set = new Set(sent.filter((e) => e.kind === 'increment'));
187
+ this.entries = this.entries.filter((e) => !set.has(e));
188
+ }
189
+
190
+ /**
191
+ * Un-acked absolute values, for the in-app sync request: where each key will
192
+ * land once the log is applied. Only `set` values - those are what targeting
193
+ * reads and what can be fresher on the device than in the profile. Given an
194
+ * anonymous id, only that visitor's writes (not a previous user's leftovers).
195
+ */
196
+ pending(anonymousId?: string): Record<string, any> {
197
+ const out: Record<string, any> = {};
198
+ for (const e of this.entries) {
199
+ if (anonymousId && e.owner && e.owner.anonymousId !== anonymousId) continue;
200
+ if (e.kind === 'set') out[e.key] = e.value;
201
+ else if (e.kind === 'unset') delete out[e.key];
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /** The log as data, for storage while consent is pending. */
207
+ serialize(): AttributeEntry[] {
208
+ return this.entries.map((e) => {
209
+ const copy: AttributeEntry = { key: e.key, kind: e.kind };
210
+ if (e.kind === 'set' || e.kind === 'increment') copy.value = e.value;
211
+ if (e.values) copy.values = [...e.values];
212
+ if (e.owner) copy.owner = { ...e.owner };
213
+ if (e.attempts) copy.attempts = e.attempts;
214
+ return copy;
215
+ });
216
+ }
217
+
218
+ /** Re-add stored entries (in order) after anything already recorded. */
219
+ restore(stored: unknown): void {
220
+ if (!Array.isArray(stored)) return;
221
+ for (const raw of stored) {
222
+ if (!raw || typeof raw !== 'object' || typeof raw.key !== 'string') continue;
223
+ const kind = raw.kind as AttributeOpKind;
224
+ const owner: WriteOwner | undefined =
225
+ raw.owner && typeof raw.owner === 'object' && typeof raw.owner.anonymousId === 'string'
226
+ ? { anonymousId: raw.owner.anonymousId, ...(typeof raw.owner.userId === 'string' ? { userId: raw.owner.userId } : {}) }
227
+ : undefined;
228
+ const before = this.entries.length;
229
+ if (kind === 'set') this.add('set', raw.key, raw.value, owner);
230
+ else if (kind === 'unset') this.add('unset', raw.key, undefined, owner);
231
+ else if (kind === 'increment' && typeof raw.value === 'number' && Number.isFinite(raw.value)) {
232
+ this.add('increment', raw.key, raw.value, owner);
233
+ } else if ((kind === 'append' || kind === 'remove') && Array.isArray(raw.values)) {
234
+ this.add(kind, raw.key, raw.values, owner);
235
+ }
236
+ const added = this.entries.length > before ? this.entries[this.entries.length - 1] : undefined;
237
+ if (added && Number.isInteger(raw.attempts) && raw.attempts > 0) added.attempts = raw.attempts;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The identity is about to change (reset, wipe, identify of another user):
243
+ * writes made while `anonymousId` was anonymous belong to `userId`, who
244
+ * that anonymous id became. Pinned now, they keep going to that person.
245
+ */
246
+ pinOwner(anonymousId: string, userId: string): void {
247
+ for (const e of this.entries) {
248
+ if (!e.owner) e.owner = { anonymousId, userId };
249
+ else if (e.owner.anonymousId === anonymousId && !e.owner.userId) e.owner = { anonymousId, userId };
250
+ }
251
+ }
252
+
253
+ clear(): void {
254
+ this.entries = [];
255
+ }
256
+
257
+ /**
258
+ * One request: the oldest waiting entry of every key, all of ONE person (the
259
+ * owner of the oldest sendable entry). An entry of another person keeps its
260
+ * key's place, so a key's writes still go in call order across people.
261
+ */
262
+ private build(markInFlight: boolean, resolve: OwnerResolver = (o) => o): AttributeRequest | null {
263
+ const busy = new Set(this.entries.filter((e) => e.inFlight).map((e) => e.key));
264
+ const used = new Set<string>();
265
+ const ops: AttributeOps = {};
266
+ const picked: AttributeEntry[] = [];
267
+ let owner: WriteOwner | undefined;
268
+ let ownerChosen = false;
269
+ for (const e of this.entries) {
270
+ if (e.inFlight || busy.has(e.key) || used.has(e.key)) {
271
+ used.add(e.key);
272
+ continue;
273
+ }
274
+ const who = resolve(e.owner);
275
+ if (ownerChosen && !sameOwner(who, owner)) {
276
+ used.add(e.key);
277
+ continue;
278
+ }
279
+ if (!ownerChosen) {
280
+ ownerChosen = true;
281
+ owner = who;
282
+ }
283
+ used.add(e.key);
284
+ picked.push(e);
285
+ switch (e.kind) {
286
+ case 'set':
287
+ (ops.attributes ??= {})[e.key] = e.value;
288
+ break;
289
+ case 'unset':
290
+ (ops.unset ??= []).push(e.key);
291
+ break;
292
+ case 'increment':
293
+ (ops.increment ??= {})[e.key] = e.value;
294
+ break;
295
+ case 'append':
296
+ (ops.append ??= {})[e.key] = [...e.values!];
297
+ break;
298
+ case 'remove':
299
+ (ops.remove ??= {})[e.key] = [...e.values!];
300
+ break;
301
+ }
302
+ }
303
+ if (!picked.length) return null;
304
+ if (markInFlight) for (const e of picked) e.inFlight = true;
305
+ return { ops, entries: picked, ...(owner ? { owner: { ...owner } } : {}) };
306
+ }
307
+
308
+ private lastFor(key: string): AttributeEntry | undefined {
309
+ for (let i = this.entries.length - 1; i >= 0; i--) {
310
+ if (this.entries[i].key === key) return this.entries[i];
311
+ }
312
+ return undefined;
313
+ }
314
+
315
+ /** Bound memory if an app writes unique keys in a loop while offline (oldest first). */
316
+ private trim(): void {
317
+ while (this.entries.length > this.maxEntries) {
318
+ const i = this.entries.findIndex((e) => !e.inFlight);
319
+ if (i < 0) break;
320
+ this.entries.splice(i, 1);
321
+ }
322
+ }
323
+ }
324
+
325
+ /** The API treats an array value as "each element", so a stored list is flat. */
326
+ function flatten(value: any): any[] {
327
+ return Array.isArray(value) ? [...value] : [value];
328
+ }
329
+
330
+ function includesValue(list: any[], v: any): boolean {
331
+ if (v !== null && typeof v === 'object') {
332
+ const json = safeJson(v);
333
+ return list.some((x) => x !== null && typeof x === 'object' && safeJson(x) === json);
334
+ }
335
+ return list.some((x) => Object.is(x, v));
336
+ }
337
+
338
+ function dedupe(values: any[]): any[] {
339
+ const out: any[] = [];
340
+ for (const v of values) if (!includesValue(out, v)) out.push(v);
341
+ return out;
342
+ }
343
+
344
+ function safeJson(v: any): string {
345
+ try {
346
+ return JSON.stringify(v);
347
+ } catch {
348
+ return String(v);
349
+ }
350
+ }