@koolbase/js 10.0.1 → 10.2.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 CHANGED
@@ -7,6 +7,58 @@ is based on [Keep a Changelog][kac], and this project adheres to
7
7
  [kac]: https://keepachangelog.com/en/1.1.0/
8
8
  [semver]: https://semver.org/
9
9
 
10
+ ## 10.2.0
11
+
12
+ ### Added
13
+
14
+ - **Multiple tabs are coordinated.** Tabs on one origin share one IndexedDB
15
+ and one offline queue. Two locks, through the Web Locks API, now keep them
16
+ honest: a short exclusive lock around every read-modify-write of the
17
+ offline state, so two tabs enqueueing at once cannot overwrite each other;
18
+ and a lease on replaying the queue, held for a whole flush including its
19
+ HTTP calls, so two tabs coming online together send each queued write once
20
+ rather than once each. A tab that cannot take the lease skips its pass and
21
+ rechecks once the holder is done, so a write queued during another tab's
22
+ flush is not stranded. A tab that closes mid-flush releases its lock
23
+ automatically. In a browser without Web Locks — none current — offline
24
+ queueing refuses with an explicit error rather than risking a double send.
25
+
26
+ Proven with a contended fake lock manager: two tabs, one queued write, one
27
+ HTTP request. Exercised in Chrome with real Web Locks.
28
+
29
+ ## 10.1.0
30
+
31
+ ### Fixed
32
+
33
+ - **Uploads could not work from a browser.** `upload()` required
34
+ `{ uri, name, type }` — a React Native shape — and fetched that URI to get
35
+ the bytes. A browser `File` has no `uri`, so every upload from the web
36
+ failed before it reached the network, while the README showed a file input
37
+ as if it worked. `file` now accepts a `Blob` or `File` directly and the
38
+ React Native form is unchanged. Round-tripped in a browser: upload,
39
+ download, byte-for-byte comparison.
40
+
41
+ ### Added
42
+
43
+ - **`storageTier()`** on the browser adapter — `'indexeddb'`,
44
+ `'localstorage'` or `'memory'`. Storage is now chosen by *trying* each
45
+ store rather than checking whether the API exists: Safari in private
46
+ browsing exposes `indexedDB` and may refuse to open it, and a browser with
47
+ site data blocked exposes `localStorage` and throws on write. A failure at
48
+ any tier falls to the next, ending in memory with one console warning. An
49
+ app can read the tier and tell the user their session will not survive a
50
+ reload.
51
+
52
+ ## 10.0.2
53
+
54
+ ### Fixed
55
+
56
+ - **A Node process that initialized the SDK never exited.** The analytics
57
+ flush interval, and a pending realtime reconnect, counted as work on Node's
58
+ event loop, so a CLI, a test runner or an SSR build step hung after its last
59
+ line. Both timers are now unref'd where the runtime supports it. A browser
60
+ is unaffected: the page keeps itself alive regardless.
61
+
10
62
  ## 10.0.1
11
63
 
12
64
  ### Fixed
package/README.md CHANGED
@@ -168,11 +168,13 @@ becomes a conflict you resolve: `resolveWithLocal()`, `resolveWithServer()`,
168
168
  `resolveWithMerge({...})`, or `abandon()`. Conflicts survive reloads and do not
169
169
  expire; surface them if you support offline editing.
170
170
 
171
- **Single tab, in this release.** Two tabs share one IndexedDB and one queue,
172
- and both may replay the same write. Inserts are idempotent so the damage is
173
- bounded, but a conflict resolved in one tab can be re-resolved in another.
174
- Multi-tab coordination is on the roadmap; until then, treat the offline queue
175
- as belonging to one tab.
171
+ **Multiple tabs are coordinated.** Tabs share one IndexedDB and one queue, and
172
+ the SDK uses the Web Locks API so that only one tab replays a queued write and
173
+ state changes from different tabs cannot overwrite each other. A tab that
174
+ closes mid-flush releases its lock automatically; the next tab picks up
175
+ whatever remains. Every current browser has Web Locks; in one that does not,
176
+ offline queueing is disabled with an explicit error rather than risking a
177
+ write being sent twice.
176
178
 
177
179
  ---
178
180
 
@@ -1,2 +1,25 @@
1
1
  import type { PlatformAdapter } from '@koolbase/core';
2
- export declare function browserPlatform(): PlatformAdapter;
2
+ /**
3
+ * What a browser is actually willing to persist, decided by trying.
4
+ *
5
+ * Feature detection is not capability detection. Safari in private browsing
6
+ * exposes `indexedDB` and then refuses to open a database; a browser with
7
+ * site data blocked exposes `localStorage` and throws on write; a storage
8
+ * quota can be exhausted at any point afterwards. A selector that checks
9
+ * `typeof` commits to a store that may never work.
10
+ *
11
+ * So the store is resolved on first use, by using it, and the result is
12
+ * cached. A failure at any tier falls to the next, ending in memory — which
13
+ * always works and persists nothing.
14
+ */
15
+ type Tier = 'indexeddb' | 'localstorage' | 'memory';
16
+ /**
17
+ * The browser adapter, plus what only a browser needs: which storage tier it
18
+ * settled on. Not on PlatformAdapter, because no other host has tiers — React
19
+ * Native's keychain either works or its package is absent.
20
+ */
21
+ export interface BrowserPlatformAdapter extends PlatformAdapter {
22
+ storageTier(): Promise<Tier>;
23
+ }
24
+ export declare function browserPlatform(): BrowserPlatformAdapter;
25
+ export {};
@@ -11,11 +11,9 @@ const auth_storage_js_1 = require("./auth-storage.js");
11
11
  //
12
12
  // What this adapter does NOT do, stated so it is not discovered later:
13
13
  //
14
- // - Multi-tab coordination. Two tabs share one IndexedDB and one write
15
- // queue; both may replay the same pending write. Inserts are idempotent
16
- // on the server (ids are UUIDs from birth), so the damage is bounded, but
17
- // a conflict resolved in one tab can be re-resolved in another. v1 is
18
- // single-tab; a leader election is the fix and belongs in its own change.
14
+ // - Coordinate across origins. Tabs on one origin share a queue and are
15
+ // coordinated through Web Locks (see webLocks below); nothing coordinates
16
+ // two different origins, which is correct they are different apps.
19
17
  //
20
18
  // - Secure token storage. There is no keychain in a browser. Anything
21
19
  // JavaScript can read, a script injected by XSS can read. The core's
@@ -107,11 +105,128 @@ function memoryStorage() {
107
105
  getAllKeys: async () => Array.from(m.keys()),
108
106
  };
109
107
  }
108
+ async function probe(store) {
109
+ const k = '__koolbase_probe__';
110
+ try {
111
+ await store.setItem(k, '1');
112
+ const v = await store.getItem(k);
113
+ await store.removeItem(k);
114
+ return v === '1';
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ function makeResolver() {
121
+ // Per adapter, not per module: two adapters must not share a store, or a
122
+ // server that built one client per request would hand one request's
123
+ // session to the next.
124
+ let resolved = null;
125
+ let resolving = null;
126
+ return () => {
127
+ if (resolved)
128
+ return Promise.resolve(resolved);
129
+ resolving ?? (resolving = (async () => {
130
+ if (typeof indexedDB !== 'undefined') {
131
+ const s = indexedDBStorage();
132
+ if (await probe(s))
133
+ return { tier: 'indexeddb', store: s };
134
+ }
135
+ if (typeof localStorage !== 'undefined') {
136
+ const s = localStorageStorage();
137
+ if (await probe(s))
138
+ return { tier: 'localstorage', store: s };
139
+ }
140
+ // eslint-disable-next-line no-console
141
+ console.warn('[Koolbase] No persistent storage available in this browser ' +
142
+ '(private browsing, blocked site data, or an exhausted quota). ' +
143
+ 'The session and the offline queue will not survive a reload.');
144
+ return { tier: 'memory', store: memoryStorage() };
145
+ })());
146
+ return resolving.then((r) => { resolved = r; return r; });
147
+ };
148
+ }
149
+ /**
150
+ * Web Locks. One holder per name across every tab on the origin, with
151
+ * ownership released when a tab closes or crashes — which is why this and
152
+ * not a BroadcastChannel election with heartbeats and a dead-leader timeout.
153
+ *
154
+ * Where the API is absent there is deliberately no fallback to running the
155
+ * function unlocked: that is exactly the uncoordinated behaviour the lock
156
+ * exists to prevent, and doing it silently would be worse than refusing.
157
+ * Every browser current enough to run this SDK has Web Locks (Safari 15.4+,
158
+ * Chrome 69+, Firefox 96+), so this is a guard, not a limitation.
159
+ */
160
+ function webLocks() {
161
+ // Tabs are what need coordinating, and a tab has a window. No window means
162
+ // server rendering, a build step, a Node tool, a worker — nothing else can
163
+ // be sharing this store, so run the work. Not navigator: Node 22 ships a
164
+ // navigator object without locks, and testing for it would refuse every
165
+ // server environment the memory fallback exists to support.
166
+ if (typeof window === 'undefined') {
167
+ return {
168
+ exclusive: (_name, fn) => fn(),
169
+ tryExclusive: async (_name, fn) => { await fn(); return { ran: true }; },
170
+ };
171
+ }
172
+ // A browser that has navigator but no locks is a browser too old to
173
+ // coordinate. That one refuses: tabs exist, and running unlocked is the
174
+ // duplicate-replay risk this lock was added to remove.
175
+ if (typeof navigator === 'undefined' || !('locks' in navigator)) {
176
+ const refuse = () => {
177
+ throw new Error('[Koolbase] This browser has no Web Locks API, so the SDK cannot ' +
178
+ 'coordinate offline writes between tabs. Offline queueing is ' +
179
+ 'disabled rather than risking a write being replayed twice.');
180
+ };
181
+ return {
182
+ exclusive: async () => refuse(),
183
+ tryExclusive: async () => refuse(),
184
+ };
185
+ }
186
+ const locks = navigator.locks;
187
+ return {
188
+ // request() resolves with whatever the callback resolves to; the DOM
189
+ // typings describe the callback's return as the result, so an async
190
+ // callback types as Promise<Promise<T>>. The runtime awaits it.
191
+ exclusive: (name, fn) => locks.request(name, fn),
192
+ tryExclusive: async (name, fn) => {
193
+ let ran = false;
194
+ await locks.request(name, { ifAvailable: true }, async (lock) => {
195
+ // A null lock means another tab holds it. Skip: it is already
196
+ // replaying the same queue, and waiting would only duplicate the
197
+ // wait, not the work.
198
+ if (!lock)
199
+ return;
200
+ ran = true;
201
+ await fn();
202
+ });
203
+ return { ran };
204
+ },
205
+ };
206
+ }
110
207
  function browserPlatform() {
111
- const storage = typeof indexedDB !== 'undefined' ? indexedDBStorage()
112
- : typeof localStorage !== 'undefined' ? localStorageStorage()
113
- : memoryStorage();
208
+ const resolve = makeResolver();
209
+ // Every call resolves first, so a browser that only reveals its refusal on
210
+ // use is handled here rather than by the caller.
211
+ const storage = {
212
+ getItem: async (k) => (await resolve()).store.getItem(k),
213
+ setItem: async (k, v) => { await (await resolve()).store.setItem(k, v); },
214
+ removeItem: async (k) => { await (await resolve()).store.removeItem(k); },
215
+ getAllKeys: async () => (await resolve()).store.getAllKeys(),
216
+ // Reaches through to the resolved tier. Only IndexedDB holds a
217
+ // connection worth closing; the test harness needs it released before
218
+ // it can delete the database between cases, and without this the
219
+ // façade hid the handle and every browser test hung on its hook.
220
+ close: async () => {
221
+ const r = await resolve();
222
+ await r.store.close?.();
223
+ },
224
+ };
225
+ // Which tier this adapter settled on: 'indexeddb', 'localstorage' or
226
+ // 'memory'. An app that cares can warn the user before they lose a session.
227
+ const storageTier = async () => (await resolve()).tier;
114
228
  return {
229
+ storageTier,
115
230
  storage,
116
231
  network: {
117
232
  onChange: (cb) => {
@@ -153,6 +268,7 @@ function browserPlatform() {
153
268
  os: 'web',
154
269
  version: browserVersion(),
155
270
  },
271
+ locks: webLocks(),
156
272
  // IndexedDB-backed; see auth-storage.ts for what that does and does not
157
273
  // protect against.
158
274
  authStorage: () => new auth_storage_js_1.BrowserAuthStorage(),
@@ -1,2 +1,25 @@
1
1
  import type { PlatformAdapter } from '@koolbase/core';
2
- export declare function browserPlatform(): PlatformAdapter;
2
+ /**
3
+ * What a browser is actually willing to persist, decided by trying.
4
+ *
5
+ * Feature detection is not capability detection. Safari in private browsing
6
+ * exposes `indexedDB` and then refuses to open a database; a browser with
7
+ * site data blocked exposes `localStorage` and throws on write; a storage
8
+ * quota can be exhausted at any point afterwards. A selector that checks
9
+ * `typeof` commits to a store that may never work.
10
+ *
11
+ * So the store is resolved on first use, by using it, and the result is
12
+ * cached. A failure at any tier falls to the next, ending in memory — which
13
+ * always works and persists nothing.
14
+ */
15
+ type Tier = 'indexeddb' | 'localstorage' | 'memory';
16
+ /**
17
+ * The browser adapter, plus what only a browser needs: which storage tier it
18
+ * settled on. Not on PlatformAdapter, because no other host has tiers — React
19
+ * Native's keychain either works or its package is absent.
20
+ */
21
+ export interface BrowserPlatformAdapter extends PlatformAdapter {
22
+ storageTier(): Promise<Tier>;
23
+ }
24
+ export declare function browserPlatform(): BrowserPlatformAdapter;
25
+ export {};
@@ -8,11 +8,9 @@ import { BrowserAuthStorage } from './auth-storage.js';
8
8
  //
9
9
  // What this adapter does NOT do, stated so it is not discovered later:
10
10
  //
11
- // - Multi-tab coordination. Two tabs share one IndexedDB and one write
12
- // queue; both may replay the same pending write. Inserts are idempotent
13
- // on the server (ids are UUIDs from birth), so the damage is bounded, but
14
- // a conflict resolved in one tab can be re-resolved in another. v1 is
15
- // single-tab; a leader election is the fix and belongs in its own change.
11
+ // - Coordinate across origins. Tabs on one origin share a queue and are
12
+ // coordinated through Web Locks (see webLocks below); nothing coordinates
13
+ // two different origins, which is correct they are different apps.
16
14
  //
17
15
  // - Secure token storage. There is no keychain in a browser. Anything
18
16
  // JavaScript can read, a script injected by XSS can read. The core's
@@ -104,11 +102,128 @@ function memoryStorage() {
104
102
  getAllKeys: async () => Array.from(m.keys()),
105
103
  };
106
104
  }
105
+ async function probe(store) {
106
+ const k = '__koolbase_probe__';
107
+ try {
108
+ await store.setItem(k, '1');
109
+ const v = await store.getItem(k);
110
+ await store.removeItem(k);
111
+ return v === '1';
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ function makeResolver() {
118
+ // Per adapter, not per module: two adapters must not share a store, or a
119
+ // server that built one client per request would hand one request's
120
+ // session to the next.
121
+ let resolved = null;
122
+ let resolving = null;
123
+ return () => {
124
+ if (resolved)
125
+ return Promise.resolve(resolved);
126
+ resolving ?? (resolving = (async () => {
127
+ if (typeof indexedDB !== 'undefined') {
128
+ const s = indexedDBStorage();
129
+ if (await probe(s))
130
+ return { tier: 'indexeddb', store: s };
131
+ }
132
+ if (typeof localStorage !== 'undefined') {
133
+ const s = localStorageStorage();
134
+ if (await probe(s))
135
+ return { tier: 'localstorage', store: s };
136
+ }
137
+ // eslint-disable-next-line no-console
138
+ console.warn('[Koolbase] No persistent storage available in this browser ' +
139
+ '(private browsing, blocked site data, or an exhausted quota). ' +
140
+ 'The session and the offline queue will not survive a reload.');
141
+ return { tier: 'memory', store: memoryStorage() };
142
+ })());
143
+ return resolving.then((r) => { resolved = r; return r; });
144
+ };
145
+ }
146
+ /**
147
+ * Web Locks. One holder per name across every tab on the origin, with
148
+ * ownership released when a tab closes or crashes — which is why this and
149
+ * not a BroadcastChannel election with heartbeats and a dead-leader timeout.
150
+ *
151
+ * Where the API is absent there is deliberately no fallback to running the
152
+ * function unlocked: that is exactly the uncoordinated behaviour the lock
153
+ * exists to prevent, and doing it silently would be worse than refusing.
154
+ * Every browser current enough to run this SDK has Web Locks (Safari 15.4+,
155
+ * Chrome 69+, Firefox 96+), so this is a guard, not a limitation.
156
+ */
157
+ function webLocks() {
158
+ // Tabs are what need coordinating, and a tab has a window. No window means
159
+ // server rendering, a build step, a Node tool, a worker — nothing else can
160
+ // be sharing this store, so run the work. Not navigator: Node 22 ships a
161
+ // navigator object without locks, and testing for it would refuse every
162
+ // server environment the memory fallback exists to support.
163
+ if (typeof window === 'undefined') {
164
+ return {
165
+ exclusive: (_name, fn) => fn(),
166
+ tryExclusive: async (_name, fn) => { await fn(); return { ran: true }; },
167
+ };
168
+ }
169
+ // A browser that has navigator but no locks is a browser too old to
170
+ // coordinate. That one refuses: tabs exist, and running unlocked is the
171
+ // duplicate-replay risk this lock was added to remove.
172
+ if (typeof navigator === 'undefined' || !('locks' in navigator)) {
173
+ const refuse = () => {
174
+ throw new Error('[Koolbase] This browser has no Web Locks API, so the SDK cannot ' +
175
+ 'coordinate offline writes between tabs. Offline queueing is ' +
176
+ 'disabled rather than risking a write being replayed twice.');
177
+ };
178
+ return {
179
+ exclusive: async () => refuse(),
180
+ tryExclusive: async () => refuse(),
181
+ };
182
+ }
183
+ const locks = navigator.locks;
184
+ return {
185
+ // request() resolves with whatever the callback resolves to; the DOM
186
+ // typings describe the callback's return as the result, so an async
187
+ // callback types as Promise<Promise<T>>. The runtime awaits it.
188
+ exclusive: (name, fn) => locks.request(name, fn),
189
+ tryExclusive: async (name, fn) => {
190
+ let ran = false;
191
+ await locks.request(name, { ifAvailable: true }, async (lock) => {
192
+ // A null lock means another tab holds it. Skip: it is already
193
+ // replaying the same queue, and waiting would only duplicate the
194
+ // wait, not the work.
195
+ if (!lock)
196
+ return;
197
+ ran = true;
198
+ await fn();
199
+ });
200
+ return { ran };
201
+ },
202
+ };
203
+ }
107
204
  export function browserPlatform() {
108
- const storage = typeof indexedDB !== 'undefined' ? indexedDBStorage()
109
- : typeof localStorage !== 'undefined' ? localStorageStorage()
110
- : memoryStorage();
205
+ const resolve = makeResolver();
206
+ // Every call resolves first, so a browser that only reveals its refusal on
207
+ // use is handled here rather than by the caller.
208
+ const storage = {
209
+ getItem: async (k) => (await resolve()).store.getItem(k),
210
+ setItem: async (k, v) => { await (await resolve()).store.setItem(k, v); },
211
+ removeItem: async (k) => { await (await resolve()).store.removeItem(k); },
212
+ getAllKeys: async () => (await resolve()).store.getAllKeys(),
213
+ // Reaches through to the resolved tier. Only IndexedDB holds a
214
+ // connection worth closing; the test harness needs it released before
215
+ // it can delete the database between cases, and without this the
216
+ // façade hid the handle and every browser test hung on its hook.
217
+ close: async () => {
218
+ const r = await resolve();
219
+ await r.store.close?.();
220
+ },
221
+ };
222
+ // Which tier this adapter settled on: 'indexeddb', 'localstorage' or
223
+ // 'memory'. An app that cares can warn the user before they lose a session.
224
+ const storageTier = async () => (await resolve()).tier;
111
225
  return {
226
+ storageTier,
112
227
  storage,
113
228
  network: {
114
229
  onChange: (cb) => {
@@ -150,6 +265,7 @@ export function browserPlatform() {
150
265
  os: 'web',
151
266
  version: browserVersion(),
152
267
  },
268
+ locks: webLocks(),
153
269
  // IndexedDB-backed; see auth-storage.ts for what that does and does not
154
270
  // protect against.
155
271
  authStorage: () => new BrowserAuthStorage(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/js",
3
- "version": "10.0.1",
3
+ "version": "10.2.0",
4
4
  "description": "Koolbase SDK for the browser \u2014 auth, database, storage, realtime, functions, flags and offline sync in one package.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "url": "https://github.com/koolbase/koolbase-react-native"
26
26
  },
27
27
  "dependencies": {
28
- "@koolbase/core": "10.0.1"
28
+ "@koolbase/core": "10.2.0"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"