@abraca/dabra 2.16.0 → 2.17.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
@@ -1616,6 +1616,18 @@ type onAuthenticatedParameters = {
1616
1616
  type onOpenParameters = {
1617
1617
  event: Event;
1618
1618
  };
1619
+ /**
1620
+ * Fired when a socket re-establishes after having previously been open at
1621
+ * least once on this provider — i.e. an actual reconnect, not the first
1622
+ * connect. Consumers use this to re-run a reconciliation pass (e.g. re-scan a
1623
+ * watched column) after a drop. The provider's Y.Doc is never replaced, so any
1624
+ * `observe`/`observeDeep` listeners are still attached and fire on their own as
1625
+ * the post-reconnect sync delivers updates — this event is a belt-and-braces
1626
+ * "we're live again" signal, not a requirement for observers to keep working.
1627
+ */
1628
+ type onReconnectedParameters = {
1629
+ event: Event;
1630
+ };
1619
1631
  type onMessageParameters = {
1620
1632
  event: MessageEvent;
1621
1633
  message: IncomingMessage;
@@ -2244,6 +2256,22 @@ declare class AbracadabraWS extends EventEmitter {
2244
2256
  get serverUrl(): string;
2245
2257
  get url(): string;
2246
2258
  disconnect(): void;
2259
+ /**
2260
+ * Force a fresh socket on the SAME WS manager: drop the current connection
2261
+ * (if any) and reconnect immediately.
2262
+ *
2263
+ * Unlike {@link disconnect} (which clears `shouldConnect` and gives up) this
2264
+ * re-arms `shouldConnect`, so it also revives a socket that a permanent
2265
+ * permission-denial turned off (see `AbracadabraBaseProvider.permissionDeniedHandler`).
2266
+ * The WS manager and its `providerMap` are preserved, so every attached
2267
+ * provider — and every Y.Doc + observer riding this socket — survives
2268
+ * untouched; only the transport is recycled.
2269
+ *
2270
+ * The existing socket is torn down silently (handlers removed first), so no
2271
+ * `close` event fires and no second reconnect is scheduled — `connect()`
2272
+ * below is the single path that re-opens. Returns the `connect()` promise.
2273
+ */
2274
+ reconnect(): Promise<unknown>;
2247
2275
  send(message: any): void;
2248
2276
  onClose({
2249
2277
  event
@@ -2587,6 +2615,7 @@ interface CompleteAbracadabraBaseProviderConfiguration {
2587
2615
  onAuthenticationFailed: (data: onAuthenticationFailedParameters) => void;
2588
2616
  onRateLimited: () => void;
2589
2617
  onOpen: (data: onOpenParameters) => void;
2618
+ onReconnected: (data: onReconnectedParameters) => void;
2590
2619
  onConnect: () => void;
2591
2620
  onStatus: (data: onStatusParameters) => void;
2592
2621
  onMessage: (data: onMessageParameters) => void;
@@ -2620,6 +2649,13 @@ declare class AbracadabraBaseProvider extends EventEmitter {
2620
2649
  * Reset on close.
2621
2650
  */
2622
2651
  private _hasEverAuthenticated;
2652
+ /**
2653
+ * True once this provider's socket has opened at least once. Used to fire
2654
+ * the `reconnected` event on every *subsequent* open (an actual reconnect)
2655
+ * but not the very first connect. Persists across closes — a reconnect is
2656
+ * still a reconnect even after the socket dropped.
2657
+ */
2658
+ private _hasOpenedBefore;
2623
2659
  /** Current WebSocket connection status. */
2624
2660
  get connectionStatus(): WebSocketStatus;
2625
2661
  authorizedScope: AuthorizedScope | undefined;
@@ -2695,6 +2731,25 @@ declare class AbracadabraBaseProvider extends EventEmitter {
2695
2731
  receiveStateless(payload: string): void;
2696
2732
  connect(): Promise<unknown>;
2697
2733
  disconnect(): void;
2734
+ /**
2735
+ * Force the underlying socket to drop and reconnect on the SAME Y.Doc.
2736
+ *
2737
+ * Unlike destroying and re-creating the provider, the document is never
2738
+ * replaced — so every `observe`/`observeDeep` listener attached to it (and
2739
+ * its subdocs) keeps firing once the socket comes back. This also revives a
2740
+ * socket that a permanent permission-denial gave up on (see
2741
+ * {@link permissionDeniedHandler}). Pair it with a freshly-minted token (the
2742
+ * `token` callback is re-invoked on every reconnect) when healing a drop that
2743
+ * was caused by an expired JWT.
2744
+ *
2745
+ * Every provider multiplexed onto this socket is marked unsynced so a
2746
+ * subsequent `waitForSync` actually waits for the post-reconnect handshake
2747
+ * instead of short-circuiting on the stale `isSynced` flag.
2748
+ *
2749
+ * No-op (with a warning) for a provider on a shared, externally-managed
2750
+ * socket — reconnect the owning `websocketProvider` directly in that case.
2751
+ */
2752
+ reconnect(): Promise<unknown> | undefined;
2698
2753
  onOpen(event: Event): Promise<void>;
2699
2754
  getToken(): Promise<string | null>;
2700
2755
  startSync(): void;
@@ -5349,4 +5404,26 @@ declare function patchEntry(treeMap: Y.Map<unknown>, id: string, patch: Record<s
5349
5404
  allowLabelClear?: boolean;
5350
5405
  }): void;
5351
5406
  //#endregion
5352
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AdminConfigField, AdminConfigOriginKind, AuditLogEntry, AuditQueryOpts, AuditVerifyResult, AuthFailureContext, AuthFailureReason, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, ChannelKeyResolver, type ChatChannel, ChatClient, type ChatClientTransport, type MarkReadInput as ChatMarkReadInput, type ChatMessage, type ChatReadCursor, type ChatReadReceipt, type ChatTypingEvent, type ChildrenPage, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, ContentManager, type CreateNotificationInput, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DeleteMessageInput, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceSessionRecord, type DeviceSessionStorage, type DeviceTier, type DocEncryptionInfo, DocKeyManager, DocSearchHit, type DocSyncState, type DocSyncStatus, type DocumentBlock, DocumentCache, type DocumentCacheOptions, type DocumentContent, DocumentManager, type DocumentManagerConfig, DocumentMeta, type DocumentMetaInfo, type DocumentMetaWire, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, type EditMessageInput, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedChatClient, EncryptedYMap, EncryptedYText, EnvSnapshotExtension, EnvSnapshotItem, EnvSnapshotResponse, type FetchInboxInput, type FetchNotificationsInput, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, type FoldedMessage, Forbidden, GEO_TYPE_META_SCHEMAS, type GetChatHistoryInput, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, type InboxEntry, type MarkReadInput$1 as InboxMarkReadInput, InviteRow, KEY_EXCHANGE_CHANNEL, Kind, LocalStorageDeviceSessionStorage, ManualSignaling, type ManualSignalingBlob, type MessageRecord, MessageTooBig, MessageType, MetaFieldType, MetaManager, MetaValidationError, type NotificationReadUpdate, type NotificationRecord, NotificationsClient, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PAGE_TYPES, PageMeta, PageTypeInfo, PageTypeMetaField, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, QUERY_PREFIX, QueryClient, QueryError, type QueryFrame, type QueryKind, type QuerySpec, type QuerySubscriptionHandle, type QuerySubscriptionHandlers, type QueryTransport, RPC_PREFIX, ReadyzStatus, ResetConnection, type RpcCallHandle, type RpcCallOptions, RpcClient, RpcError, type RpcErrorCode, type RpcErrorPayload, type RpcFrame, type RpcHandler, type RpcHandlerContext, type RpcKind, type RpcTarget, type RpcTransport, SERVER_ROOT_ID, type SchemaDocTypeName, type SchemaMetaOf, type SchemaRegistryLike, type SchemaValidatorLike, SearchIndex, SearchResult, type SendChatMessageInput, type SendMessageInput, type SendMessageResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotFileEntry, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, StatesArray, SubdocMessage, SubdocRegisteredEvent, TYPE_ALIASES, TokenManager, type TokenManagerOptions, TreeEntry, TreeManager, TreeNode, TreeSearchResult, TypedDocTypeMismatchError, type TypedDocsClient, type TypedTreeEntry, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserMetaField, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, buildBlockquoteElement, buildBlocksFromMarkdown, buildBulletListElement, buildCodeBlockElement, buildHeadingElement, buildHorizontalRuleElement, buildOrderedListElement, buildParagraphElement, buildTaskListElement, decryptChatContent, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptChatContent, encryptField, filenameToLabel, foldRecords, generateMnemonic, isEncryptedContent, isPlaceholderLabel, makeEncryptedYMap, makeEncryptedYText, makeEntryMap, mnemonicToEd25519Seed, mnemonicToKeyPair, normalizeRootId, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onCompactedParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, parseFrontmatter, patchEntry, populateYDocFromMarkdown, readAuthMessage, readBlocksFromFragment, recordFromYAny, resolvePageType, toPlain, unwrapSeed, validateMnemonic, waitForSync, withTimeout, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest, yjsToMarkdown };
5407
+ //#region packages/provider/src/CoverReconcile.d.ts
5408
+ interface MediaFileBlock {
5409
+ uploadId: string;
5410
+ docId: string;
5411
+ mimeType: string;
5412
+ }
5413
+ /**
5414
+ * Recursively collect image/video `fileBlock` nodes from a Y.XmlFragment (or
5415
+ * element subtree), in document order. Mirrors the TipTap
5416
+ * `doc.descendants()` walk used by cou-sh — fileBlocks may be nested inside
5417
+ * other block nodes, so this recurses rather than scanning only top level.
5418
+ */
5419
+ declare function collectMediaFileBlocks(root: Y.XmlFragment | Y.XmlElement): MediaFileBlock[];
5420
+ /**
5421
+ * Reconcile the cover metadata of `docId`'s tree entry against the media file
5422
+ * blocks currently present in `fragment`. No-op when the entry is missing or
5423
+ * the cover is already valid, so it is safe (and cheap) to call after every
5424
+ * content write. The `treeMap` is the root doc-tree Y.Map; the cover lives on
5425
+ * the doc's OWN self-entry (`treeMap.get(docId).meta`).
5426
+ */
5427
+ declare function reconcileDocCover(treeMap: Y.Map<unknown>, docId: string, fragment: Y.XmlFragment | Y.XmlElement): void;
5428
+ //#endregion
5429
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AdminConfigField, AdminConfigOriginKind, AuditLogEntry, AuditQueryOpts, AuditVerifyResult, AuthFailureContext, AuthFailureReason, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, ChannelKeyResolver, type ChatChannel, ChatClient, type ChatClientTransport, type MarkReadInput as ChatMarkReadInput, type ChatMessage, type ChatReadCursor, type ChatReadReceipt, type ChatTypingEvent, type ChildrenPage, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, ContentManager, type CreateNotificationInput, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DeleteMessageInput, DevicePairingChannel, type DevicePairingConfig, DeviceRegistrationService, type DeviceServerStatus, type DeviceSessionRecord, type DeviceSessionStorage, type DeviceTier, type DocEncryptionInfo, DocKeyManager, DocSearchHit, type DocSyncState, type DocSyncStatus, type DocumentBlock, DocumentCache, type DocumentCacheOptions, type DocumentContent, DocumentManager, type DocumentManagerConfig, DocumentMeta, type DocumentMetaInfo, type DocumentMetaWire, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, type EditMessageInput, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedChatClient, EncryptedYMap, EncryptedYText, EnvSnapshotExtension, EnvSnapshotItem, EnvSnapshotResponse, type FetchInboxInput, type FetchNotificationsInput, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, type FoldedMessage, Forbidden, GEO_TYPE_META_SCHEMAS, type GetChatHistoryInput, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, type InboxEntry, type MarkReadInput$1 as InboxMarkReadInput, InviteRow, KEY_EXCHANGE_CHANNEL, Kind, LocalStorageDeviceSessionStorage, ManualSignaling, type ManualSignalingBlob, type MediaFileBlock, type MessageRecord, MessageTooBig, MessageType, MetaFieldType, MetaManager, MetaValidationError, type NotificationReadUpdate, type NotificationRecord, NotificationsClient, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PAGE_TYPES, PageMeta, PageTypeInfo, PageTypeMetaField, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, QUERY_PREFIX, QueryClient, QueryError, type QueryFrame, type QueryKind, type QuerySpec, type QuerySubscriptionHandle, type QuerySubscriptionHandlers, type QueryTransport, RPC_PREFIX, ReadyzStatus, ResetConnection, type RpcCallHandle, type RpcCallOptions, RpcClient, RpcError, type RpcErrorCode, type RpcErrorPayload, type RpcFrame, type RpcHandler, type RpcHandlerContext, type RpcKind, type RpcTarget, type RpcTransport, SERVER_ROOT_ID, type SchemaDocTypeName, type SchemaMetaOf, type SchemaRegistryLike, type SchemaValidatorLike, SearchIndex, SearchResult, type SendChatMessageInput, type SendMessageInput, type SendMessageResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SnapshotCreateResult, SnapshotData, SnapshotFileEntry, SnapshotForkResult, SnapshotMeta, SnapshotRestoreResult, StatesArray, SubdocMessage, SubdocRegisteredEvent, TYPE_ALIASES, TokenManager, type TokenManagerOptions, TreeEntry, TreeManager, TreeNode, TreeSearchResult, TypedDocTypeMismatchError, type TypedDocsClient, type TypedTreeEntry, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserMetaField, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, wordlist as bip39Wordlist, buildBlockquoteElement, buildBlocksFromMarkdown, buildBulletListElement, buildCodeBlockElement, buildHeadingElement, buildHorizontalRuleElement, buildOrderedListElement, buildParagraphElement, buildTaskListElement, collectMediaFileBlocks, decryptChatContent, decryptField, deriveIdentityDocId, deriveSeedWrappingKey, encryptChatContent, encryptField, filenameToLabel, foldRecords, generateMnemonic, isEncryptedContent, isPlaceholderLabel, makeEncryptedYMap, makeEncryptedYText, makeEntryMap, mnemonicToEd25519Seed, mnemonicToKeyPair, normalizeRootId, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onCompactedParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onReconnectedParameters, onServerErrorParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, parseFrontmatter, patchEntry, populateYDocFromMarkdown, readAuthMessage, readBlocksFromFragment, reconcileDocCover, recordFromYAny, resolvePageType, toPlain, unwrapSeed, validateMnemonic, waitForSync, withTimeout, wrapSeed, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest, yjsToMarkdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "2.16.0",
3
+ "version": "2.17.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.16.0"
44
+ "@abraca/schema": "2.17.0"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
@@ -29,6 +29,7 @@ import type {
29
29
  onMessageParameters,
30
30
  onOpenParameters,
31
31
  onOutgoingMessageParameters,
32
+ onReconnectedParameters,
32
33
  onServerErrorParameters,
33
34
  onStatelessParameters,
34
35
  onStatusParameters,
@@ -89,6 +90,7 @@ export interface CompleteAbracadabraBaseProviderConfiguration {
89
90
  onAuthenticationFailed: (data: onAuthenticationFailedParameters) => void;
90
91
  onRateLimited: () => void;
91
92
  onOpen: (data: onOpenParameters) => void;
93
+ onReconnected: (data: onReconnectedParameters) => void;
92
94
  onConnect: () => void;
93
95
  onStatus: (data: onStatusParameters) => void;
94
96
  onMessage: (data: onMessageParameters) => void;
@@ -124,6 +126,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
124
126
  onAuthenticationFailed: () => null,
125
127
  onRateLimited: () => null,
126
128
  onOpen: () => null,
129
+ onReconnected: () => null,
127
130
  onConnect: () => null,
128
131
  onMessage: () => null,
129
132
  onOutgoingMessage: () => null,
@@ -155,6 +158,14 @@ export class AbracadabraBaseProvider extends EventEmitter {
155
158
  */
156
159
  private _hasEverAuthenticated = false;
157
160
 
161
+ /**
162
+ * True once this provider's socket has opened at least once. Used to fire
163
+ * the `reconnected` event on every *subsequent* open (an actual reconnect)
164
+ * but not the very first connect. Persists across closes — a reconnect is
165
+ * still a reconnect even after the socket dropped.
166
+ */
167
+ private _hasOpenedBefore = false;
168
+
158
169
  /** Current WebSocket connection status. */
159
170
  get connectionStatus(): WebSocketStatus {
160
171
  return this.configuration.websocketProvider.status;
@@ -230,6 +241,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
230
241
  }
231
242
 
232
243
  this.on("open", this.configuration.onOpen);
244
+ this.on("reconnected", this.configuration.onReconnected);
233
245
  this.on("message", this.configuration.onMessage);
234
246
  this.on("outgoingMessage", this.configuration.onOutgoingMessage);
235
247
  this.on("synced", this.configuration.onSynced);
@@ -485,10 +497,55 @@ export class AbracadabraBaseProvider extends EventEmitter {
485
497
  );
486
498
  }
487
499
 
500
+ /**
501
+ * Force the underlying socket to drop and reconnect on the SAME Y.Doc.
502
+ *
503
+ * Unlike destroying and re-creating the provider, the document is never
504
+ * replaced — so every `observe`/`observeDeep` listener attached to it (and
505
+ * its subdocs) keeps firing once the socket comes back. This also revives a
506
+ * socket that a permanent permission-denial gave up on (see
507
+ * {@link permissionDeniedHandler}). Pair it with a freshly-minted token (the
508
+ * `token` callback is re-invoked on every reconnect) when healing a drop that
509
+ * was caused by an expired JWT.
510
+ *
511
+ * Every provider multiplexed onto this socket is marked unsynced so a
512
+ * subsequent `waitForSync` actually waits for the post-reconnect handshake
513
+ * instead of short-circuiting on the stale `isSynced` flag.
514
+ *
515
+ * No-op (with a warning) for a provider on a shared, externally-managed
516
+ * socket — reconnect the owning `websocketProvider` directly in that case.
517
+ */
518
+ reconnect() {
519
+ if (!this.manageSocket) {
520
+ console.warn(
521
+ "AbracadabraBaseProvider::reconnect() — this provider shares an externally-managed socket; call reconnect() on the websocketProvider instead.",
522
+ );
523
+ return;
524
+ }
525
+
526
+ // Reset the synced flag of every provider on this socket BEFORE recycling
527
+ // it. `cleanupWebSocket` tears the socket down silently (no close event),
528
+ // so the providers' own `onClose` won't run to flip `synced` — do it here.
529
+ for (const provider of this.configuration.websocketProvider.configuration.providerMap.values()) {
530
+ provider.synced = false;
531
+ provider.isAuthenticated = false;
532
+ }
533
+
534
+ return this.configuration.websocketProvider.reconnect();
535
+ }
536
+
488
537
  async onOpen(event: Event) {
538
+ const isReopen = this._hasOpenedBefore;
539
+ this._hasOpenedBefore = true;
489
540
  this.isAuthenticated = false;
490
541
 
491
542
  this.emit("open", { event });
543
+ // A subsequent open means the socket dropped and came back — let consumers
544
+ // re-reconcile. Observers on `document` survive regardless (the doc is
545
+ // never replaced); this is the explicit signal for catch-up work.
546
+ if (isReopen) {
547
+ this.emit("reconnected", { event });
548
+ }
492
549
  await this.sendToken();
493
550
  this.startSync();
494
551
  }
@@ -578,6 +578,45 @@ export class AbracadabraWS extends EventEmitter {
578
578
  }
579
579
  }
580
580
 
581
+ /**
582
+ * Force a fresh socket on the SAME WS manager: drop the current connection
583
+ * (if any) and reconnect immediately.
584
+ *
585
+ * Unlike {@link disconnect} (which clears `shouldConnect` and gives up) this
586
+ * re-arms `shouldConnect`, so it also revives a socket that a permanent
587
+ * permission-denial turned off (see `AbracadabraBaseProvider.permissionDeniedHandler`).
588
+ * The WS manager and its `providerMap` are preserved, so every attached
589
+ * provider — and every Y.Doc + observer riding this socket — survives
590
+ * untouched; only the transport is recycled.
591
+ *
592
+ * The existing socket is torn down silently (handlers removed first), so no
593
+ * `close` event fires and no second reconnect is scheduled — `connect()`
594
+ * below is the single path that re-opens. Returns the `connect()` promise.
595
+ */
596
+ reconnect() {
597
+ this.shouldConnect = true;
598
+
599
+ // Cancel any in-flight retryer so we don't stack two connection attempts.
600
+ if (this.cancelWebsocketRetry) {
601
+ this.cancelWebsocketRetry();
602
+ this.cancelWebsocketRetry = undefined;
603
+ }
604
+
605
+ // Silently drop the current socket (cleanupWebSocket removes our handlers
606
+ // before closing, so onClose never runs → no auto-reschedule).
607
+ if (this.webSocket) {
608
+ this.cleanupWebSocket();
609
+ }
610
+
611
+ // connect() early-returns when status is Connected; force Disconnected so
612
+ // it always builds a fresh socket. Surface the transition so status
613
+ // listeners (and the providers' synced reset) see the drop.
614
+ this.status = WebSocketStatus.Disconnected;
615
+ this.emit("status", { status: WebSocketStatus.Disconnected });
616
+
617
+ return this.connect();
618
+ }
619
+
581
620
  send(message: any) {
582
621
  if (this.webSocket?.readyState === WsReadyStates.Open) {
583
622
  this.webSocket.send(message);
@@ -6,6 +6,7 @@
6
6
  import * as Y from "yjs";
7
7
  import type { PageMeta } from "./DocTypes.ts";
8
8
  import { toPlain, patchEntry } from "./DocUtils.ts";
9
+ import { reconcileDocCover } from "./CoverReconcile.ts";
9
10
  import {
10
11
  yjsToMarkdown,
11
12
  populateYDocFromMarkdown,
@@ -167,6 +168,13 @@ export class ContentManager {
167
168
  const contentToWrite = body || markdown;
168
169
  const fallbackTitle = title || "Untitled";
169
170
  populateYDocFromMarkdown(fragment, contentToWrite, fallbackTitle);
171
+
172
+ // Reconcile the cover against the media blocks now in the body, so a
173
+ // write that removed (or added) the cover's image keeps the persisted
174
+ // cover metadata honest — the editor is no longer the only path that
175
+ // does this. Cheap no-op when the cover is already valid.
176
+ const reconcileTree = this.dm.getTreeMap();
177
+ if (reconcileTree) reconcileDocCover(reconcileTree, docId, fragment);
170
178
  }
171
179
 
172
180
  private async _appendElements(docId: string, els: Y.XmlElement[]): Promise<void> {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * CoverReconcile — keep a document's cover-image metadata in sync with the
3
+ * image/video file blocks that actually live in its content.
4
+ *
5
+ * The cover (`meta.coverUploadId` / `coverDocId` / `coverMimeType` on the
6
+ * doc's own tree entry) is *persisted* metadata, not derived live — the
7
+ * kanban/gallery/table views read it without loading each card's content.
8
+ * Historically the only thing that reconciled it was cou-sh's TipTap editor
9
+ * (`reconcileCover` in DocRenderer.vue), so any write that did NOT go through
10
+ * that editor — MCP `write_document`, `@abraca/convert`, the CLI, another SDK
11
+ * — could leave an orphaned cover pointing at an image the body no longer
12
+ * contains. This is the shared, write-path-level reconciliation so every
13
+ * content write through the SDK keeps the cover honest.
14
+ *
15
+ * Semantics mirror cou-sh's editor reconciler exactly:
16
+ * - cover set + still references a present image/video → leave it (a
17
+ * user-chosen cover is preserved as long as its image is in the doc);
18
+ * - cover set + its image is gone, others remain → swap to the first;
19
+ * - cover set + no image/video remains → clear the three cover keys;
20
+ * - no cover + at least one image/video → adopt the first as the cover.
21
+ */
22
+ import * as Y from "yjs";
23
+ import { toPlain, patchEntry } from "./DocUtils.ts";
24
+
25
+ export interface MediaFileBlock {
26
+ uploadId: string;
27
+ docId: string;
28
+ mimeType: string;
29
+ }
30
+
31
+ /**
32
+ * Recursively collect image/video `fileBlock` nodes from a Y.XmlFragment (or
33
+ * element subtree), in document order. Mirrors the TipTap
34
+ * `doc.descendants()` walk used by cou-sh — fileBlocks may be nested inside
35
+ * other block nodes, so this recurses rather than scanning only top level.
36
+ */
37
+ export function collectMediaFileBlocks(
38
+ root: Y.XmlFragment | Y.XmlElement,
39
+ ): MediaFileBlock[] {
40
+ const out: MediaFileBlock[] = [];
41
+ const visit = (node: Y.AbstractType<unknown>): void => {
42
+ if (node instanceof Y.XmlElement && node.nodeName === "fileBlock") {
43
+ const mimeType = node.getAttribute("mimeType");
44
+ if (
45
+ typeof mimeType === "string" &&
46
+ (mimeType.startsWith("image/") || mimeType.startsWith("video/"))
47
+ ) {
48
+ const uploadId = node.getAttribute("uploadId");
49
+ if (typeof uploadId === "string" && uploadId) {
50
+ const docId = node.getAttribute("docId");
51
+ out.push({
52
+ uploadId,
53
+ docId: typeof docId === "string" ? docId : "",
54
+ mimeType,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ if (node instanceof Y.XmlElement || node instanceof Y.XmlFragment) {
60
+ for (let i = 0; i < node.length; i++) {
61
+ visit(node.get(i) as Y.AbstractType<unknown>);
62
+ }
63
+ }
64
+ };
65
+ visit(root as unknown as Y.AbstractType<unknown>);
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Reconcile the cover metadata of `docId`'s tree entry against the media file
71
+ * blocks currently present in `fragment`. No-op when the entry is missing or
72
+ * the cover is already valid, so it is safe (and cheap) to call after every
73
+ * content write. The `treeMap` is the root doc-tree Y.Map; the cover lives on
74
+ * the doc's OWN self-entry (`treeMap.get(docId).meta`).
75
+ */
76
+ export function reconcileDocCover(
77
+ treeMap: Y.Map<unknown>,
78
+ docId: string,
79
+ fragment: Y.XmlFragment | Y.XmlElement,
80
+ ): void {
81
+ const raw = treeMap.get(docId);
82
+ if (!raw) return;
83
+
84
+ const entry = toPlain(raw) as Record<string, unknown>;
85
+ const meta = ((entry.meta as Record<string, unknown>) ?? {}) as Record<
86
+ string,
87
+ unknown
88
+ >;
89
+ const currentCoverId = meta.coverUploadId as string | undefined;
90
+
91
+ const media = collectMediaFileBlocks(fragment);
92
+
93
+ // Cover already references a present image/video → nothing to do.
94
+ if (currentCoverId && media.some((b) => b.uploadId === currentCoverId)) {
95
+ return;
96
+ }
97
+
98
+ if (media.length > 0) {
99
+ // Adopt (no cover) or swap (stale cover) to the first media block.
100
+ const best = media[0]!;
101
+ if (
102
+ currentCoverId === best.uploadId &&
103
+ meta.coverDocId === (best.docId || docId) &&
104
+ meta.coverMimeType === best.mimeType
105
+ ) {
106
+ return;
107
+ }
108
+ patchEntry(treeMap, docId, {
109
+ meta: {
110
+ ...meta,
111
+ coverUploadId: best.uploadId,
112
+ coverDocId: best.docId || docId,
113
+ coverMimeType: best.mimeType,
114
+ },
115
+ updatedAt: Date.now(),
116
+ });
117
+ return;
118
+ }
119
+
120
+ // No media remains. Clear the cover keys only if one was set.
121
+ if (!currentCoverId && !meta.coverDocId && !meta.coverMimeType) return;
122
+ const {
123
+ coverUploadId: _cu,
124
+ coverDocId: _cd,
125
+ coverMimeType: _cm,
126
+ ...rest
127
+ } = meta;
128
+ patchEntry(treeMap, docId, { meta: rest, updatedAt: Date.now() });
129
+ }
package/src/index.ts CHANGED
@@ -157,6 +157,11 @@ export {
157
157
  patchEntry,
158
158
  isPlaceholderLabel,
159
159
  } from "./DocUtils.ts";
160
+ export {
161
+ reconcileDocCover,
162
+ collectMediaFileBlocks,
163
+ } from "./CoverReconcile.ts";
164
+ export type { MediaFileBlock } from "./CoverReconcile.ts";
160
165
  export {
161
166
  yjsToMarkdown,
162
167
  populateYDocFromMarkdown,
package/src/types.ts CHANGED
@@ -83,6 +83,19 @@ export type onOpenParameters = {
83
83
  event: Event;
84
84
  };
85
85
 
86
+ /**
87
+ * Fired when a socket re-establishes after having previously been open at
88
+ * least once on this provider — i.e. an actual reconnect, not the first
89
+ * connect. Consumers use this to re-run a reconciliation pass (e.g. re-scan a
90
+ * watched column) after a drop. The provider's Y.Doc is never replaced, so any
91
+ * `observe`/`observeDeep` listeners are still attached and fire on their own as
92
+ * the post-reconnect sync delivers updates — this event is a belt-and-braces
93
+ * "we're live again" signal, not a requirement for observers to keep working.
94
+ */
95
+ export type onReconnectedParameters = {
96
+ event: Event;
97
+ };
98
+
86
99
  export type onMessageParameters = {
87
100
  event: MessageEvent;
88
101
  message: IncomingMessage;