@koolbase/core 10.1.0 → 10.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.
@@ -4,13 +4,27 @@ export declare class KoolbaseAnalytics {
4
4
  private queue;
5
5
  private deviceId;
6
6
  private userId?;
7
+ private getSignedInUser?;
7
8
  private environmentId?;
8
9
  private userProperties;
9
10
  private sessionId;
10
11
  private appVersion;
11
12
  private flushTimer?;
12
13
  private initialized;
13
- constructor(config: KoolbaseConfig);
14
+ /**
15
+ * getSignedInUser lets events carry the signed-in user without the app
16
+ * having to say so.
17
+ *
18
+ * identify() existed and nothing errored when an app never called it, so
19
+ * every event landed anonymous and retention, funnels and per-user
20
+ * analysis were quietly worthless — found in a real project as 53 events,
21
+ * 8 registered users and not one event carrying a user id. The Flutter SDK
22
+ * fixed this in 11.2.0; the TypeScript SDKs kept the bug until now.
23
+ *
24
+ * identify() still wins, for an app with its own identity system, and
25
+ * reset() releases that override.
26
+ */
27
+ constructor(config: KoolbaseConfig, getSignedInUser?: () => string | null);
14
28
  init(appVersion?: string): Promise<void>;
15
29
  track(eventName: string, properties?: Record<string, unknown>): void;
16
30
  screenView(screenName: string, properties?: Record<string, unknown>): void;
@@ -9,7 +9,20 @@ const DEVICE_ID_KEY = 'koolbase:device_id';
9
9
  const FLUSH_INTERVAL_MS = 30000;
10
10
  const MAX_BATCH_SIZE = 20;
11
11
  class KoolbaseAnalytics {
12
- constructor(config) {
12
+ /**
13
+ * getSignedInUser lets events carry the signed-in user without the app
14
+ * having to say so.
15
+ *
16
+ * identify() existed and nothing errored when an app never called it, so
17
+ * every event landed anonymous and retention, funnels and per-user
18
+ * analysis were quietly worthless — found in a real project as 53 events,
19
+ * 8 registered users and not one event carrying a user id. The Flutter SDK
20
+ * fixed this in 11.2.0; the TypeScript SDKs kept the bug until now.
21
+ *
22
+ * identify() still wins, for an app with its own identity system, and
23
+ * reset() releases that override.
24
+ */
25
+ constructor(config, getSignedInUser) {
13
26
  this.queue = [];
14
27
  this.deviceId = '';
15
28
  this.userProperties = {};
@@ -17,6 +30,7 @@ class KoolbaseAnalytics {
17
30
  this.appVersion = '1.0.0';
18
31
  this.initialized = false;
19
32
  this.config = config;
33
+ this.getSignedInUser = getSignedInUser;
20
34
  }
21
35
  // ─── Init ─────────────────────────────────────────────────────────────────
22
36
  async init(appVersion) {
@@ -42,7 +56,8 @@ class KoolbaseAnalytics {
42
56
  track(eventName, properties) {
43
57
  const event = {
44
58
  device_id: this.deviceId,
45
- user_id: this.userId,
59
+ // The explicit override first, then whoever is signed in.
60
+ user_id: this.userId ?? this.getSignedInUser?.() ?? undefined,
46
61
  environment_id: this.environmentId,
47
62
  event_name: eventName,
48
63
  properties: properties ?? {},
@@ -19,4 +19,4 @@ export { getOrCreateDeviceId } from './device-id.js';
19
19
  export { koolbaseSdkVersion } from './device-metadata.js';
20
20
  export { RestoreResult } from './types.js';
21
21
  export type { AuthStateListener, FetchLike, KoolbaseAuthStorage } from './types.js';
22
- export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformInfo, } from './platform.js';
22
+ export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
@@ -70,6 +70,14 @@ export interface OfflineState {
70
70
  export declare class OfflineStateTooLargeError extends Error {
71
71
  constructor(bytes: number);
72
72
  }
73
+ /**
74
+ * Lock names derive from the storage key they protect, so the two cannot
75
+ * drift apart. Note what the key does NOT carry: a project id. Two Koolbase
76
+ * apps on one origin with the same user id already share offline state —
77
+ * a pre-existing collision these locks neither create nor fix.
78
+ */
79
+ export declare function stateLockName(userId: string): string;
80
+ export declare function flushLockName(userId: string): string;
73
81
  export declare function readOfflineState(userId: string): Promise<OfflineState>;
74
82
  /**
75
83
  * Reads, mutates, and writes the state under the user's lock.
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OfflineStateTooLargeError = void 0;
4
4
  exports.recordKey = recordKey;
5
+ exports.stateLockName = stateLockName;
6
+ exports.flushLockName = flushLockName;
5
7
  exports.readOfflineState = readOfflineState;
6
8
  exports.mutateOfflineState = mutateOfflineState;
7
9
  exports.queueWrite = queueWrite;
@@ -72,6 +74,18 @@ function byteLength(s) {
72
74
  return bytes;
73
75
  }
74
76
  const locks = () => (0, shared_js_1.shared)('locks', () => new Map());
77
+ /**
78
+ * Lock names derive from the storage key they protect, so the two cannot
79
+ * drift apart. Note what the key does NOT carry: a project id. Two Koolbase
80
+ * apps on one origin with the same user id already share offline state —
81
+ * a pre-existing collision these locks neither create nor fix.
82
+ */
83
+ function stateLockName(userId) {
84
+ return `${stateKey(userId)}:lock`;
85
+ }
86
+ function flushLockName(userId) {
87
+ return `${stateKey(userId)}:flush`;
88
+ }
75
89
  async function withLock(userId, fn) {
76
90
  const previous = locks().get(userId) ?? Promise.resolve();
77
91
  let release = () => { };
@@ -79,7 +93,10 @@ async function withLock(userId, fn) {
79
93
  locks().set(userId, previous.then(() => next));
80
94
  await previous;
81
95
  try {
82
- return await fn();
96
+ // In-process first, then across copies of the app that share this store.
97
+ // Both are needed: the promise chain orders callers within one runtime,
98
+ // the platform lock orders runtimes.
99
+ return await (0, platform_js_1.getPlatform)().locks.exclusive(stateLockName(userId), fn);
83
100
  }
84
101
  finally {
85
102
  release();
@@ -17,6 +17,31 @@ export interface PlatformLifecycle {
17
17
  /** Subscribe to the app moving to the background. Used to flush analytics. */
18
18
  onBackground(callback: () => void): () => void;
19
19
  }
20
+ /**
21
+ * Coordination between copies of the SDK that share durable storage.
22
+ *
23
+ * Two locks, different problems, different durations:
24
+ *
25
+ * exclusive short, waited for, held across a read-modify-write of the
26
+ * offline state and never across the network. Stops one copy
27
+ * overwriting another's queued write.
28
+ *
29
+ * tryExclusive a lease on the responsibility to replay the queue, held for
30
+ * a whole flush pass INCLUDING its HTTP calls, and never
31
+ * waited for. Without it two tabs read the same pending write
32
+ * and both send it: the state stays consistent and the server
33
+ * is hit twice. A copy that cannot take the lease skips,
34
+ * because whoever holds it is already doing the work.
35
+ *
36
+ * A host where the SDK cannot be running twice over one store — React Native,
37
+ * one process — satisfies both by just running the function.
38
+ */
39
+ export interface PlatformLocks {
40
+ exclusive<T>(name: string, fn: () => Promise<T>): Promise<T>;
41
+ tryExclusive(name: string, fn: () => Promise<void>): Promise<{
42
+ ran: boolean;
43
+ }>;
44
+ }
20
45
  export interface PlatformInfo {
21
46
  /** e.g. 'ios', 'android', 'web' */
22
47
  os: string;
@@ -28,6 +53,7 @@ export interface PlatformAdapter {
28
53
  network: PlatformNetwork;
29
54
  lifecycle: PlatformLifecycle;
30
55
  info: PlatformInfo;
56
+ locks: PlatformLocks;
31
57
  /**
32
58
  * The host's best persistent store for the auth session, or null if it has
33
59
  * none worth the name. Used only when the app injects nothing through
@@ -28,6 +28,12 @@ function memoryPlatform() {
28
28
  onBackground: () => () => { },
29
29
  },
30
30
  info: { os: 'memory', version: '' },
31
+ // One process, one store, and the in-process promise chain in
32
+ // offline-state already serialises it.
33
+ locks: {
34
+ exclusive: (_n, fn) => fn(),
35
+ tryExclusive: async (_n, fn) => { await fn(); return { ran: true }; },
36
+ },
31
37
  authStorage: () => null,
32
38
  };
33
39
  }
@@ -18,6 +18,9 @@ export declare class SyncEngine {
18
18
  start(): void;
19
19
  stop(): void;
20
20
  flush(): Promise<void>;
21
+ private recheckTimer;
22
+ private scheduleRecheck;
23
+ private flushHeld;
21
24
  /**
22
25
  * Sends one queued write, returning the revision the record now carries.
23
26
  *
@@ -45,6 +45,7 @@ class RevisionMismatch extends Error {
45
45
  class SyncEngine {
46
46
  constructor(config, getUserId, getToken, onSyncComplete, onSessionExpired) {
47
47
  this.isSyncing = false;
48
+ this.recheckTimer = null;
48
49
  this.config = config;
49
50
  this.getUserId = getUserId;
50
51
  this.getToken = getToken;
@@ -66,6 +67,32 @@ class SyncEngine {
66
67
  const userId = this.getUserId();
67
68
  if (!userId)
68
69
  return;
70
+ // A lease on replaying this user's queue, held for the whole pass
71
+ // including its HTTP calls. isSyncing does this within one runtime; this
72
+ // does it across every copy sharing the store. Not waited for: a copy
73
+ // that cannot take it skips, because the holder is replaying the same
74
+ // queue — waiting would duplicate the wait, not the work.
75
+ const { ran } = await (0, platform_js_1.getPlatform)().locks.tryExclusive((0, offline_state_js_1.flushLockName)(userId), () => this.flushHeld(userId));
76
+ if (!ran) {
77
+ // Skipped, and a write queued between the holder's last read and its
78
+ // release would otherwise sit until an unrelated reconnect. Re-check
79
+ // once, after the holder has had time to finish; if it is still
80
+ // running, its own recheck covers what we would have sent.
81
+ this.scheduleRecheck();
82
+ }
83
+ }
84
+ scheduleRecheck() {
85
+ // Bounded and coalesced: one pending recheck at a time, so a page with
86
+ // several tabs cannot turn a skipped flush into a retry storm.
87
+ if (this.recheckTimer)
88
+ return;
89
+ this.recheckTimer = setTimeout(() => {
90
+ this.recheckTimer = null;
91
+ void this.flush();
92
+ }, 1500);
93
+ this.recheckTimer.unref?.();
94
+ }
95
+ async flushHeld(userId) {
69
96
  this.isSyncing = true;
70
97
  try {
71
98
  // Before anything is sent. Writes queued by an earlier version sit under a
@@ -4,13 +4,27 @@ export declare class KoolbaseAnalytics {
4
4
  private queue;
5
5
  private deviceId;
6
6
  private userId?;
7
+ private getSignedInUser?;
7
8
  private environmentId?;
8
9
  private userProperties;
9
10
  private sessionId;
10
11
  private appVersion;
11
12
  private flushTimer?;
12
13
  private initialized;
13
- constructor(config: KoolbaseConfig);
14
+ /**
15
+ * getSignedInUser lets events carry the signed-in user without the app
16
+ * having to say so.
17
+ *
18
+ * identify() existed and nothing errored when an app never called it, so
19
+ * every event landed anonymous and retention, funnels and per-user
20
+ * analysis were quietly worthless — found in a real project as 53 events,
21
+ * 8 registered users and not one event carrying a user id. The Flutter SDK
22
+ * fixed this in 11.2.0; the TypeScript SDKs kept the bug until now.
23
+ *
24
+ * identify() still wins, for an app with its own identity system, and
25
+ * reset() releases that override.
26
+ */
27
+ constructor(config: KoolbaseConfig, getSignedInUser?: () => string | null);
14
28
  init(appVersion?: string): Promise<void>;
15
29
  track(eventName: string, properties?: Record<string, unknown>): void;
16
30
  screenView(screenName: string, properties?: Record<string, unknown>): void;
@@ -6,7 +6,20 @@ const DEVICE_ID_KEY = 'koolbase:device_id';
6
6
  const FLUSH_INTERVAL_MS = 30000;
7
7
  const MAX_BATCH_SIZE = 20;
8
8
  export class KoolbaseAnalytics {
9
- constructor(config) {
9
+ /**
10
+ * getSignedInUser lets events carry the signed-in user without the app
11
+ * having to say so.
12
+ *
13
+ * identify() existed and nothing errored when an app never called it, so
14
+ * every event landed anonymous and retention, funnels and per-user
15
+ * analysis were quietly worthless — found in a real project as 53 events,
16
+ * 8 registered users and not one event carrying a user id. The Flutter SDK
17
+ * fixed this in 11.2.0; the TypeScript SDKs kept the bug until now.
18
+ *
19
+ * identify() still wins, for an app with its own identity system, and
20
+ * reset() releases that override.
21
+ */
22
+ constructor(config, getSignedInUser) {
10
23
  this.queue = [];
11
24
  this.deviceId = '';
12
25
  this.userProperties = {};
@@ -14,6 +27,7 @@ export class KoolbaseAnalytics {
14
27
  this.appVersion = '1.0.0';
15
28
  this.initialized = false;
16
29
  this.config = config;
30
+ this.getSignedInUser = getSignedInUser;
17
31
  }
18
32
  // ─── Init ─────────────────────────────────────────────────────────────────
19
33
  async init(appVersion) {
@@ -39,7 +53,8 @@ export class KoolbaseAnalytics {
39
53
  track(eventName, properties) {
40
54
  const event = {
41
55
  device_id: this.deviceId,
42
- user_id: this.userId,
56
+ // The explicit override first, then whoever is signed in.
57
+ user_id: this.userId ?? this.getSignedInUser?.() ?? undefined,
43
58
  environment_id: this.environmentId,
44
59
  event_name: eventName,
45
60
  properties: properties ?? {},
@@ -19,4 +19,4 @@ export { getOrCreateDeviceId } from './device-id.js';
19
19
  export { koolbaseSdkVersion } from './device-metadata.js';
20
20
  export { RestoreResult } from './types.js';
21
21
  export type { AuthStateListener, FetchLike, KoolbaseAuthStorage } from './types.js';
22
- export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformInfo, } from './platform.js';
22
+ export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
@@ -70,6 +70,14 @@ export interface OfflineState {
70
70
  export declare class OfflineStateTooLargeError extends Error {
71
71
  constructor(bytes: number);
72
72
  }
73
+ /**
74
+ * Lock names derive from the storage key they protect, so the two cannot
75
+ * drift apart. Note what the key does NOT carry: a project id. Two Koolbase
76
+ * apps on one origin with the same user id already share offline state —
77
+ * a pre-existing collision these locks neither create nor fix.
78
+ */
79
+ export declare function stateLockName(userId: string): string;
80
+ export declare function flushLockName(userId: string): string;
73
81
  export declare function readOfflineState(userId: string): Promise<OfflineState>;
74
82
  /**
75
83
  * Reads, mutates, and writes the state under the user's lock.
@@ -63,6 +63,18 @@ function byteLength(s) {
63
63
  return bytes;
64
64
  }
65
65
  const locks = () => shared('locks', () => new Map());
66
+ /**
67
+ * Lock names derive from the storage key they protect, so the two cannot
68
+ * drift apart. Note what the key does NOT carry: a project id. Two Koolbase
69
+ * apps on one origin with the same user id already share offline state —
70
+ * a pre-existing collision these locks neither create nor fix.
71
+ */
72
+ export function stateLockName(userId) {
73
+ return `${stateKey(userId)}:lock`;
74
+ }
75
+ export function flushLockName(userId) {
76
+ return `${stateKey(userId)}:flush`;
77
+ }
66
78
  async function withLock(userId, fn) {
67
79
  const previous = locks().get(userId) ?? Promise.resolve();
68
80
  let release = () => { };
@@ -70,7 +82,10 @@ async function withLock(userId, fn) {
70
82
  locks().set(userId, previous.then(() => next));
71
83
  await previous;
72
84
  try {
73
- return await fn();
85
+ // In-process first, then across copies of the app that share this store.
86
+ // Both are needed: the promise chain orders callers within one runtime,
87
+ // the platform lock orders runtimes.
88
+ return await getPlatform().locks.exclusive(stateLockName(userId), fn);
74
89
  }
75
90
  finally {
76
91
  release();
@@ -17,6 +17,31 @@ export interface PlatformLifecycle {
17
17
  /** Subscribe to the app moving to the background. Used to flush analytics. */
18
18
  onBackground(callback: () => void): () => void;
19
19
  }
20
+ /**
21
+ * Coordination between copies of the SDK that share durable storage.
22
+ *
23
+ * Two locks, different problems, different durations:
24
+ *
25
+ * exclusive short, waited for, held across a read-modify-write of the
26
+ * offline state and never across the network. Stops one copy
27
+ * overwriting another's queued write.
28
+ *
29
+ * tryExclusive a lease on the responsibility to replay the queue, held for
30
+ * a whole flush pass INCLUDING its HTTP calls, and never
31
+ * waited for. Without it two tabs read the same pending write
32
+ * and both send it: the state stays consistent and the server
33
+ * is hit twice. A copy that cannot take the lease skips,
34
+ * because whoever holds it is already doing the work.
35
+ *
36
+ * A host where the SDK cannot be running twice over one store — React Native,
37
+ * one process — satisfies both by just running the function.
38
+ */
39
+ export interface PlatformLocks {
40
+ exclusive<T>(name: string, fn: () => Promise<T>): Promise<T>;
41
+ tryExclusive(name: string, fn: () => Promise<void>): Promise<{
42
+ ran: boolean;
43
+ }>;
44
+ }
20
45
  export interface PlatformInfo {
21
46
  /** e.g. 'ios', 'android', 'web' */
22
47
  os: string;
@@ -28,6 +53,7 @@ export interface PlatformAdapter {
28
53
  network: PlatformNetwork;
29
54
  lifecycle: PlatformLifecycle;
30
55
  info: PlatformInfo;
56
+ locks: PlatformLocks;
31
57
  /**
32
58
  * The host's best persistent store for the auth session, or null if it has
33
59
  * none worth the name. Used only when the app injects nothing through
@@ -23,6 +23,12 @@ export function memoryPlatform() {
23
23
  onBackground: () => () => { },
24
24
  },
25
25
  info: { os: 'memory', version: '' },
26
+ // One process, one store, and the in-process promise chain in
27
+ // offline-state already serialises it.
28
+ locks: {
29
+ exclusive: (_n, fn) => fn(),
30
+ tryExclusive: async (_n, fn) => { await fn(); return { ran: true }; },
31
+ },
26
32
  authStorage: () => null,
27
33
  };
28
34
  }
@@ -18,6 +18,9 @@ export declare class SyncEngine {
18
18
  start(): void;
19
19
  stop(): void;
20
20
  flush(): Promise<void>;
21
+ private recheckTimer;
22
+ private scheduleRecheck;
23
+ private flushHeld;
21
24
  /**
22
25
  * Sends one queued write, returning the revision the record now carries.
23
26
  *
@@ -1,4 +1,4 @@
1
- import { readOfflineState, mutateOfflineState, migrateLegacyQueue, } from './offline-state.js';
1
+ import { readOfflineState, mutateOfflineState, migrateLegacyQueue, flushLockName, } from './offline-state.js';
2
2
  import { KoolbaseUnauthenticatedError } from './errors.js';
3
3
  import { getPlatform } from './platform.js';
4
4
  import { invalidateCache, removeCachedRecord, } from './cache-store.js';
@@ -42,6 +42,7 @@ class RevisionMismatch extends Error {
42
42
  export class SyncEngine {
43
43
  constructor(config, getUserId, getToken, onSyncComplete, onSessionExpired) {
44
44
  this.isSyncing = false;
45
+ this.recheckTimer = null;
45
46
  this.config = config;
46
47
  this.getUserId = getUserId;
47
48
  this.getToken = getToken;
@@ -63,6 +64,32 @@ export class SyncEngine {
63
64
  const userId = this.getUserId();
64
65
  if (!userId)
65
66
  return;
67
+ // A lease on replaying this user's queue, held for the whole pass
68
+ // including its HTTP calls. isSyncing does this within one runtime; this
69
+ // does it across every copy sharing the store. Not waited for: a copy
70
+ // that cannot take it skips, because the holder is replaying the same
71
+ // queue — waiting would duplicate the wait, not the work.
72
+ const { ran } = await getPlatform().locks.tryExclusive(flushLockName(userId), () => this.flushHeld(userId));
73
+ if (!ran) {
74
+ // Skipped, and a write queued between the holder's last read and its
75
+ // release would otherwise sit until an unrelated reconnect. Re-check
76
+ // once, after the holder has had time to finish; if it is still
77
+ // running, its own recheck covers what we would have sent.
78
+ this.scheduleRecheck();
79
+ }
80
+ }
81
+ scheduleRecheck() {
82
+ // Bounded and coalesced: one pending recheck at a time, so a page with
83
+ // several tabs cannot turn a skipped flush into a retry storm.
84
+ if (this.recheckTimer)
85
+ return;
86
+ this.recheckTimer = setTimeout(() => {
87
+ this.recheckTimer = null;
88
+ void this.flush();
89
+ }, 1500);
90
+ this.recheckTimer.unref?.();
91
+ }
92
+ async flushHeld(userId) {
66
93
  this.isSyncing = true;
67
94
  try {
68
95
  // Before anything is sent. Writes queued by an earlier version sit under a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "10.1.0",
3
+ "version": "10.3.0",
4
4
  "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",