@abraca/dabra 2.19.0 → 2.21.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/dist/index.d.ts CHANGED
@@ -158,9 +158,12 @@ declare class OfflineStore {
158
158
  private db;
159
159
  /**
160
160
  * @param docId The document UUID.
161
- * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
162
- * When provided the IndexedDB database is namespaced
163
- * per-server, preventing cross-server data contamination.
161
+ * @param serverOrigin Host of the server, including a non-default port
162
+ * (e.g. "abra.cou.sh", "localhost:3001"). When provided
163
+ * the IndexedDB database is namespaced per-server,
164
+ * preventing cross-server data contamination — the port
165
+ * matters because two same-host servers sharing the
166
+ * default root_doc_id would otherwise collide.
164
167
  */
165
168
  constructor(docId: string, serverOrigin?: string);
166
169
  private dbPromise;
@@ -1257,6 +1260,7 @@ interface AbracadabraProviderConfiguration extends Omit<AbracadabraBaseProviderC
1257
1260
  */
1258
1261
  declare class AbracadabraProvider extends AbracadabraBaseProvider {
1259
1262
  effectiveRole: EffectiveRole;
1263
+ private _writeDropWarned;
1260
1264
  private _client;
1261
1265
  private offlineStore;
1262
1266
  private childProviders;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "2.19.0",
3
+ "version": "2.21.0",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -41,7 +41,7 @@
41
41
  "yjs": "^13.6.8"
42
42
  },
43
43
  "devDependencies": {
44
- "@abraca/schema": "2.19.0"
44
+ "@abraca/schema": "2.21.0"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -717,9 +717,18 @@ export class AbracadabraBaseProvider extends EventEmitter {
717
717
 
718
718
  this.configuration.websocketProvider.on("rateLimited", this.forwardRateLimited);
719
719
 
720
- this.configuration.websocketProvider.attach(this);
721
-
720
+ // Mark attached BEFORE registering with the socket: when the shared
721
+ // socket is already connected (the loadChild case), wsp.attach()
722
+ // synchronously invokes onOpen() → sendToken(), and send() drops frames
723
+ // while _isAttached is false. With a crypto identity OBJECT sendToken()
724
+ // hits no await before send(), so the AUTH frame was silently swallowed
725
+ // and the doc never authenticated on the connection — the server then
726
+ // (correctly) ignored every subsequent frame for it: child providers
727
+ // never synced and their writes were dropped. (JWT auth only survived
728
+ // by accident: `await getToken()` defers send() past attach().)
722
729
  this._isAttached = true;
730
+
731
+ this.configuration.websocketProvider.attach(this);
723
732
  }
724
733
 
725
734
  permissionDeniedHandler(reason: string) {
@@ -113,6 +113,10 @@ function isValidDocId(id: string): boolean {
113
113
  export class AbracadabraProvider extends AbracadabraBaseProvider {
114
114
  public effectiveRole: EffectiveRole = null;
115
115
 
116
+ // Throttle the "write-drop" warning/event to once per (role) transition so a
117
+ // read-only editor session doesn't flood the console on every keystroke.
118
+ private _writeDropWarned = false;
119
+
116
120
  private _client: AbracadabraClient | null;
117
121
  private offlineStore: OfflineStore | null;
118
122
  private childProviders = new Map<string, AbracadabraProvider>();
@@ -240,7 +244,13 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
240
244
  config.url ??
241
245
  (config.websocketProvider as AbracadabraWS | undefined)?.url ??
242
246
  client?.wsUrl;
243
- if (url) return new URL(url).hostname;
247
+ // `host` (NOT `hostname`): the port is part of a server's identity.
248
+ // Two servers on the same host but different ports would otherwise
249
+ // share one IDB namespace — and since default-config servers also
250
+ // share the same root_doc_id, one server's cached doc state silently
251
+ // hydrated into the other's Y.Doc (cross-server contamination that
252
+ // can then sync back upstream).
253
+ if (url) return new URL(url).host;
244
254
  } catch {
245
255
  // Malformed URL — fall back to no scoping
246
256
  }
@@ -281,6 +291,10 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
281
291
  // ── Auth / permission snapshot ────────────────────────────────────────────
282
292
 
283
293
  override authenticatedHandler(scope: string) {
294
+ // NB: super.authenticatedHandler emits "authenticated" BEFORE we set
295
+ // effectiveRole below, so listeners of "authenticated" must not read the
296
+ // role from this provider — use the "roleChanged" event emitted at the end
297
+ // of this method instead, which fires once effectiveRole reflects `scope`.
284
298
  super.authenticatedHandler(scope);
285
299
 
286
300
  const roleMap: Record<string, import("./types.ts").EffectiveRole> = {
@@ -293,9 +307,28 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
293
307
  "read-write": "editor",
294
308
  readonly: "viewer",
295
309
  };
310
+ const couldWrite = this.canWrite;
296
311
  this.effectiveRole = roleMap[scope] ?? "observer";
297
312
 
298
313
  this.offlineStore?.savePermissionSnapshot(this.effectiveRole);
314
+
315
+ // Regaining write access re-arms the one-shot write-drop warning so a later
316
+ // downgrade to a read-only role surfaces again.
317
+ if (this.canWrite) this._writeDropWarned = false;
318
+
319
+ // Reactive role signal for consumers (e.g. the editor edit-gate). Fired
320
+ // AFTER effectiveRole is set so `canWrite` reflects the new role.
321
+ this.emit("roleChanged", { role: this.effectiveRole, canWrite: this.canWrite });
322
+
323
+ // Self-heal: if the server's authentication frame arrived AFTER the initial
324
+ // sync (so the "synced" flush ran while the role was still null/stale and
325
+ // skipped, see flushPendingUpdates' canWrite guard), and we now have write
326
+ // access, push any updates that were queued to IndexedDB while we couldn't
327
+ // send. Without this, edits made before auth completed stay stuck in IDB
328
+ // (visible locally, never on the server) until the next reconnect.
329
+ if (!couldWrite && this.canWrite && this.isSynced) {
330
+ this.flushPendingUpdates().catch(() => null);
331
+ }
299
332
  }
300
333
 
301
334
  /**
@@ -363,6 +396,10 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
363
396
  const role = await this.offlineStore.getPermissionSnapshot();
364
397
  if (role && !this.effectiveRole) {
365
398
  this.effectiveRole = role as EffectiveRole;
399
+ // Surface the cached role reactively so the offline edit-gate can open
400
+ // before any server round-trip. The server's authenticatedHandler will
401
+ // later override this with ground truth (and re-emit).
402
+ this.emit("roleChanged", { role: this.effectiveRole, canWrite: this.canWrite });
366
403
  }
367
404
  }
368
405
 
@@ -696,7 +733,32 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
696
733
  this.offlineStore?.persistUpdate(update).catch(() => null);
697
734
 
698
735
  // Don't send writes over the wire if we lack write permission.
699
- if (!this.canWrite) return;
736
+ if (!this.canWrite) {
737
+ // Make the otherwise-silent write-drop observable. A local edit was just
738
+ // persisted to IndexedDB but will NOT be sent to the server because this
739
+ // provider's effective role can't write. If the editor UI let the user
740
+ // type here, the UI's write-gate diverged from this provider's — the edit
741
+ // lives only in local IDB (looks fine locally, empty on the server). We
742
+ // emit a "writeDropped" event so consumers can surface a read-only /
743
+ // "unsynced changes" banner, and warn once per role-transition so a
744
+ // read-only session doesn't flood the console on every keystroke.
745
+ if (!this._writeDropWarned) {
746
+ this._writeDropWarned = true;
747
+ try {
748
+ console.warn(
749
+ `[abra:write-drop] dropping wire-send for doc "${this.configuration?.name}" ` +
750
+ `— effectiveRole=${this.effectiveRole ?? "null"} (canWrite=false). ` +
751
+ `Edits are persisted to IndexedDB only; the server will NOT receive them ` +
752
+ `until this doc regains write access.`,
753
+ );
754
+ } catch { /* noop */ }
755
+ }
756
+ this.emit("writeDropped", {
757
+ name: this.configuration?.name,
758
+ role: this.effectiveRole ?? null,
759
+ });
760
+ return;
761
+ }
700
762
 
701
763
  super.documentUpdateHandler(update, origin);
702
764
  }
@@ -165,10 +165,12 @@ export class BackgroundSyncManager extends EventEmitter {
165
165
  maxRetries: opts?.maxRetries ?? 2,
166
166
  };
167
167
 
168
- // Derive server origin from client URL for IDB namespacing
168
+ // Derive server origin from client URL for IDB namespacing.
169
+ // `host` (not `hostname`): port-scoped so same-host servers on
170
+ // different ports don't share a namespace.
169
171
  let serverOrigin = "default";
170
172
  try {
171
- serverOrigin = new URL((client as any).baseUrl ?? "").hostname;
173
+ serverOrigin = new URL((client as any).baseUrl ?? "").host;
172
174
  } catch {}
173
175
 
174
176
  this.persistence = new BackgroundSyncPersistence(serverOrigin);
@@ -377,10 +379,10 @@ export class BackgroundSyncManager extends EventEmitter {
377
379
  docIds.add(docId);
378
380
  }
379
381
 
380
- // Derive server origin the same way the provider does
382
+ // Derive server origin the same way the provider does (`host`, port-scoped)
381
383
  let serverOrigin: string | undefined;
382
384
  try {
383
- serverOrigin = new URL((this.client as any).baseUrl ?? "").hostname;
385
+ serverOrigin = new URL((this.client as any).baseUrl ?? "").host;
384
386
  } catch {}
385
387
 
386
388
  // Clear each document's offline store contents
@@ -69,9 +69,12 @@ export class OfflineStore {
69
69
 
70
70
  /**
71
71
  * @param docId The document UUID.
72
- * @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
73
- * When provided the IndexedDB database is namespaced
74
- * per-server, preventing cross-server data contamination.
72
+ * @param serverOrigin Host of the server, including a non-default port
73
+ * (e.g. "abra.cou.sh", "localhost:3001"). When provided
74
+ * the IndexedDB database is namespaced per-server,
75
+ * preventing cross-server data contamination — the port
76
+ * matters because two same-host servers sharing the
77
+ * default root_doc_id would otherwise collide.
75
78
  */
76
79
  constructor(docId: string, serverOrigin?: string) {
77
80
  this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;