@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/abracadabra-provider.cjs +152 -0
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +151 -1
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +78 -1
- package/package.json +2 -2
- package/src/AbracadabraBaseProvider.ts +57 -0
- package/src/AbracadabraWS.ts +39 -0
- package/src/ContentManager.ts +8 -0
- package/src/CoverReconcile.ts +129 -0
- package/src/index.ts +5 -0
- package/src/types.ts +13 -0
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|
package/src/AbracadabraWS.ts
CHANGED
|
@@ -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);
|
package/src/ContentManager.ts
CHANGED
|
@@ -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;
|