@koolbase/js 10.1.0 → 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,25 @@ 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
+
10
29
  ## 10.1.0
11
30
 
12
31
  ### 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
 
@@ -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
@@ -148,6 +146,64 @@ function makeResolver() {
148
146
  return resolving.then((r) => { resolved = r; return r; });
149
147
  };
150
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
+ }
151
207
  function browserPlatform() {
152
208
  const resolve = makeResolver();
153
209
  // Every call resolves first, so a browser that only reveals its refusal on
@@ -157,6 +213,14 @@ function browserPlatform() {
157
213
  setItem: async (k, v) => { await (await resolve()).store.setItem(k, v); },
158
214
  removeItem: async (k) => { await (await resolve()).store.removeItem(k); },
159
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
+ },
160
224
  };
161
225
  // Which tier this adapter settled on: 'indexeddb', 'localstorage' or
162
226
  // 'memory'. An app that cares can warn the user before they lose a session.
@@ -204,6 +268,7 @@ function browserPlatform() {
204
268
  os: 'web',
205
269
  version: browserVersion(),
206
270
  },
271
+ locks: webLocks(),
207
272
  // IndexedDB-backed; see auth-storage.ts for what that does and does not
208
273
  // protect against.
209
274
  authStorage: () => new auth_storage_js_1.BrowserAuthStorage(),
@@ -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
@@ -145,6 +143,64 @@ function makeResolver() {
145
143
  return resolving.then((r) => { resolved = r; return r; });
146
144
  };
147
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
+ }
148
204
  export function browserPlatform() {
149
205
  const resolve = makeResolver();
150
206
  // Every call resolves first, so a browser that only reveals its refusal on
@@ -154,6 +210,14 @@ export function browserPlatform() {
154
210
  setItem: async (k, v) => { await (await resolve()).store.setItem(k, v); },
155
211
  removeItem: async (k) => { await (await resolve()).store.removeItem(k); },
156
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
+ },
157
221
  };
158
222
  // Which tier this adapter settled on: 'indexeddb', 'localstorage' or
159
223
  // 'memory'. An app that cares can warn the user before they lose a session.
@@ -201,6 +265,7 @@ export function browserPlatform() {
201
265
  os: 'web',
202
266
  version: browserVersion(),
203
267
  },
268
+ locks: webLocks(),
204
269
  // IndexedDB-backed; see auth-storage.ts for what that does and does not
205
270
  // protect against.
206
271
  authStorage: () => new BrowserAuthStorage(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/js",
3
- "version": "10.1.0",
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.1.0"
28
+ "@koolbase/core": "10.2.0"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"