@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- 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
|
+
}
|