@abraca/dabra 2.7.0 → 2.9.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 +85 -5
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +85 -6
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +37 -2
- package/package.json +2 -2
- package/src/AbracadabraWS.ts +72 -2
- package/src/DocUtils.ts +62 -3
- package/src/index.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -2212,7 +2212,19 @@ declare class AbracadabraWS extends EventEmitter {
|
|
|
2212
2212
|
resolve: (value?: any) => void;
|
|
2213
2213
|
reject: (reason?: any) => void;
|
|
2214
2214
|
} | null;
|
|
2215
|
+
private onlineListener;
|
|
2216
|
+
private offlineListener;
|
|
2215
2217
|
constructor(configuration: AbracadabraWSConfiguration);
|
|
2218
|
+
/**
|
|
2219
|
+
* Whether the device currently believes it has network connectivity.
|
|
2220
|
+
*
|
|
2221
|
+
* Treats "unknown" as online: in Node and other non-browser environments
|
|
2222
|
+
* `navigator` (or `navigator.onLine`) is absent, and we must not gate
|
|
2223
|
+
* reconnection there — only the browser exposes a trustworthy signal.
|
|
2224
|
+
*/
|
|
2225
|
+
get isOnline(): boolean;
|
|
2226
|
+
handleOnline(): void;
|
|
2227
|
+
handleOffline(): void;
|
|
2216
2228
|
receivedOnOpenPayload?: Event | undefined;
|
|
2217
2229
|
onOpen(event: Event): Promise<void>;
|
|
2218
2230
|
attach(provider: AbracadabraBaseProvider): void;
|
|
@@ -5264,6 +5276,15 @@ declare function toPlain(val: unknown): unknown;
|
|
|
5264
5276
|
* omitted; `null` is kept (a real value, e.g. top-level `parentId`).
|
|
5265
5277
|
*/
|
|
5266
5278
|
declare function makeEntryMap(fields: Record<string, unknown>): Y.Map<unknown>;
|
|
5279
|
+
/**
|
|
5280
|
+
* A label is a "placeholder" — i.e. carries no real user title — when it is
|
|
5281
|
+
* empty/whitespace, null/undefined, or the literal `"Untitled"` sentinel
|
|
5282
|
+
* (case-insensitive, so `"untitled"` from a `labelToFilename` round-trip is
|
|
5283
|
+
* caught too). The whole tree-label corruption class boils down to a
|
|
5284
|
+
* placeholder being allowed to overwrite a real title; `patchEntry` refuses
|
|
5285
|
+
* exactly that. Mirrors cou-sh's `isEmptyTreeLabel`.
|
|
5286
|
+
*/
|
|
5287
|
+
declare function isPlaceholderLabel(label: unknown): boolean;
|
|
5267
5288
|
/**
|
|
5268
5289
|
* Patch an EXISTING entry's fields per-key on its nested `Y.Map`, so a
|
|
5269
5290
|
* concurrent edit to a *different* field by a peer is preserved instead
|
|
@@ -5279,7 +5300,21 @@ declare function makeEntryMap(fields: Record<string, unknown>): Y.Map<unknown>;
|
|
|
5279
5300
|
* Self-transacting: it batches its writes in one `Y.Doc` transaction
|
|
5280
5301
|
* (a safe reentrant no-op join when already inside one), so callers
|
|
5281
5302
|
* don't need to pass or own a transaction.
|
|
5303
|
+
*
|
|
5304
|
+
* ── NO-DESTROY LABEL INVARIANT ──────────────────────────────────────────
|
|
5305
|
+
* A `label` patch that is a placeholder (empty/whitespace/"Untitled") is
|
|
5306
|
+
* DROPPED when the entry already holds a real (non-placeholder) label —
|
|
5307
|
+
* regardless of which consumer (cou-sh title-sync, fs-sync rename
|
|
5308
|
+
* detection, MCP, table renderers, a stale snapshot) tried it. This is the
|
|
5309
|
+
* source-of-truth guard against the "card title silently becomes Untitled
|
|
5310
|
+
* / files renamed to untitled.md" corruption: a placeholder must never win
|
|
5311
|
+
* over a real title. Creating a brand-new entry with an empty label (e.g.
|
|
5312
|
+
* a fresh kanban card) is still allowed — the guard only fires when a real
|
|
5313
|
+
* label already exists. Pass `{ allowLabelClear: true }` to override (the
|
|
5314
|
+
* single legitimate "user explicitly cleared it" path).
|
|
5282
5315
|
*/
|
|
5283
|
-
declare function patchEntry(treeMap: Y.Map<unknown>, id: string, patch: Record<string, unknown>, removeKeys?: string[]
|
|
5316
|
+
declare function patchEntry(treeMap: Y.Map<unknown>, id: string, patch: Record<string, unknown>, removeKeys?: string[], opts?: {
|
|
5317
|
+
allowLabelClear?: boolean;
|
|
5318
|
+
}): void;
|
|
5284
5319
|
//#endregion
|
|
5285
|
-
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 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, 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 };
|
|
5320
|
+
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 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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abraca/dabra",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.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.9.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
|
package/src/AbracadabraWS.ts
CHANGED
|
@@ -182,6 +182,12 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
182
182
|
reject: (reason?: any) => void;
|
|
183
183
|
} | null = null;
|
|
184
184
|
|
|
185
|
+
// Bound `online`/`offline` listeners, retained so we can detach them in
|
|
186
|
+
// destroy(). Only registered in browser-like environments (see constructor).
|
|
187
|
+
private onlineListener: (() => void) | null = null;
|
|
188
|
+
|
|
189
|
+
private offlineListener: (() => void) | null = null;
|
|
190
|
+
|
|
185
191
|
constructor(configuration: AbracadabraWSConfiguration) {
|
|
186
192
|
super();
|
|
187
193
|
this.setConfiguration(configuration);
|
|
@@ -210,11 +216,57 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
210
216
|
this.configuration.messageReconnectTimeout / 10,
|
|
211
217
|
);
|
|
212
218
|
|
|
219
|
+
// Offline-first reconnect gating. When the device reports it is offline we
|
|
220
|
+
// stop hammering the socket (failed attempts just flip status
|
|
221
|
+
// disconnected↔connecting and light up every connection indicator) and
|
|
222
|
+
// wait for the `online` event to resume immediately — no backoff wait.
|
|
223
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
224
|
+
this.onlineListener = this.handleOnline.bind(this);
|
|
225
|
+
this.offlineListener = this.handleOffline.bind(this);
|
|
226
|
+
window.addEventListener("online", this.onlineListener);
|
|
227
|
+
window.addEventListener("offline", this.offlineListener);
|
|
228
|
+
}
|
|
229
|
+
|
|
213
230
|
if (this.shouldConnect) {
|
|
214
231
|
this.connect();
|
|
215
232
|
}
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Whether the device currently believes it has network connectivity.
|
|
237
|
+
*
|
|
238
|
+
* Treats "unknown" as online: in Node and other non-browser environments
|
|
239
|
+
* `navigator` (or `navigator.onLine`) is absent, and we must not gate
|
|
240
|
+
* reconnection there — only the browser exposes a trustworthy signal.
|
|
241
|
+
*/
|
|
242
|
+
get isOnline(): boolean {
|
|
243
|
+
return typeof navigator === "undefined" || navigator.onLine !== false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
handleOnline() {
|
|
247
|
+
// Network came back — resume immediately if we still want a connection
|
|
248
|
+
// and aren't already connected. Bypasses the backoff that a pending
|
|
249
|
+
// retryer would otherwise wait out.
|
|
250
|
+
if (this.shouldConnect && this.status !== WebSocketStatus.Connected) {
|
|
251
|
+
this.connect();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
handleOffline() {
|
|
256
|
+
// Stop attempting while offline, but preserve `shouldConnect` so intent
|
|
257
|
+
// survives — `handleOnline`/attach will resume once connectivity returns.
|
|
258
|
+
if (this.cancelWebsocketRetry) {
|
|
259
|
+
this.cancelWebsocketRetry();
|
|
260
|
+
this.cancelWebsocketRetry = undefined;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
this.webSocket?.close();
|
|
264
|
+
this.messageQueue = [];
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error(e);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
218
270
|
receivedOnOpenPayload?: Event | undefined = undefined;
|
|
219
271
|
|
|
220
272
|
async onOpen(event: Event) {
|
|
@@ -271,6 +323,14 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
271
323
|
return;
|
|
272
324
|
}
|
|
273
325
|
|
|
326
|
+
// Don't attempt while the device is offline — record the intent and let
|
|
327
|
+
// the `online` event drive the resume. Avoids the reconnect storm of
|
|
328
|
+
// instantly-failing socket attempts against a known-dead network.
|
|
329
|
+
if (!this.isOnline) {
|
|
330
|
+
this.shouldConnect = true;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
274
334
|
// Always cancel any previously initiated connection retryer instances
|
|
275
335
|
if (this.cancelWebsocketRetry) {
|
|
276
336
|
this.cancelWebsocketRetry();
|
|
@@ -548,8 +608,11 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
548
608
|
this.emit("rateLimited");
|
|
549
609
|
}
|
|
550
610
|
|
|
551
|
-
// trigger connect if no retry is running and we want to have a connection
|
|
552
|
-
|
|
611
|
+
// trigger connect if no retry is running and we want to have a connection.
|
|
612
|
+
// Skip scheduling entirely while offline — `handleOnline` resumes the
|
|
613
|
+
// moment connectivity returns, so there's no point burning a timer (and a
|
|
614
|
+
// failed attempt) against a dead network.
|
|
615
|
+
if (!this.cancelWebsocketRetry && this.shouldConnect && this.isOnline) {
|
|
553
616
|
// Apply a much longer delay for rate-limited closes to let the server window reset.
|
|
554
617
|
const delay = isRateLimited ? 60_000 : this.configuration.delay;
|
|
555
618
|
setTimeout(() => {
|
|
@@ -565,6 +628,13 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
565
628
|
|
|
566
629
|
this.emit("destroy");
|
|
567
630
|
|
|
631
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
632
|
+
if (this.onlineListener) window.removeEventListener("online", this.onlineListener);
|
|
633
|
+
if (this.offlineListener) window.removeEventListener("offline", this.offlineListener);
|
|
634
|
+
}
|
|
635
|
+
this.onlineListener = null;
|
|
636
|
+
this.offlineListener = null;
|
|
637
|
+
|
|
568
638
|
clearInterval(this.intervals.connectionChecker);
|
|
569
639
|
|
|
570
640
|
// If there is still a connection attempt outstanding then we should stop
|
package/src/DocUtils.ts
CHANGED
|
@@ -104,6 +104,20 @@ export function makeEntryMap(
|
|
|
104
104
|
return m;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* A label is a "placeholder" — i.e. carries no real user title — when it is
|
|
109
|
+
* empty/whitespace, null/undefined, or the literal `"Untitled"` sentinel
|
|
110
|
+
* (case-insensitive, so `"untitled"` from a `labelToFilename` round-trip is
|
|
111
|
+
* caught too). The whole tree-label corruption class boils down to a
|
|
112
|
+
* placeholder being allowed to overwrite a real title; `patchEntry` refuses
|
|
113
|
+
* exactly that. Mirrors cou-sh's `isEmptyTreeLabel`.
|
|
114
|
+
*/
|
|
115
|
+
export function isPlaceholderLabel(label: unknown): boolean {
|
|
116
|
+
if (typeof label !== "string") return label == null;
|
|
117
|
+
const t = label.trim();
|
|
118
|
+
return t === "" || t.toLowerCase() === "untitled";
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
/**
|
|
108
122
|
* Patch an EXISTING entry's fields per-key on its nested `Y.Map`, so a
|
|
109
123
|
* concurrent edit to a *different* field by a peer is preserved instead
|
|
@@ -119,17 +133,62 @@ export function makeEntryMap(
|
|
|
119
133
|
* Self-transacting: it batches its writes in one `Y.Doc` transaction
|
|
120
134
|
* (a safe reentrant no-op join when already inside one), so callers
|
|
121
135
|
* don't need to pass or own a transaction.
|
|
136
|
+
*
|
|
137
|
+
* ── NO-DESTROY LABEL INVARIANT ──────────────────────────────────────────
|
|
138
|
+
* A `label` patch that is a placeholder (empty/whitespace/"Untitled") is
|
|
139
|
+
* DROPPED when the entry already holds a real (non-placeholder) label —
|
|
140
|
+
* regardless of which consumer (cou-sh title-sync, fs-sync rename
|
|
141
|
+
* detection, MCP, table renderers, a stale snapshot) tried it. This is the
|
|
142
|
+
* source-of-truth guard against the "card title silently becomes Untitled
|
|
143
|
+
* / files renamed to untitled.md" corruption: a placeholder must never win
|
|
144
|
+
* over a real title. Creating a brand-new entry with an empty label (e.g.
|
|
145
|
+
* a fresh kanban card) is still allowed — the guard only fires when a real
|
|
146
|
+
* label already exists. Pass `{ allowLabelClear: true }` to override (the
|
|
147
|
+
* single legitimate "user explicitly cleared it" path).
|
|
122
148
|
*/
|
|
123
149
|
export function patchEntry(
|
|
124
150
|
treeMap: Y.Map<unknown>,
|
|
125
151
|
id: string,
|
|
126
152
|
patch: Record<string, unknown>,
|
|
127
153
|
removeKeys: string[] = [],
|
|
154
|
+
opts: { allowLabelClear?: boolean } = {},
|
|
128
155
|
): void {
|
|
129
156
|
const apply = (): void => {
|
|
130
157
|
const raw = treeMap.get(id);
|
|
158
|
+
|
|
159
|
+
// NO-DESTROY label guard: strip a placeholder `label` from the patch
|
|
160
|
+
// when a real label already exists on the entry. The existing-label
|
|
161
|
+
// read is DUCK-TYPED (not `instanceof Y.Map`): under a host that
|
|
162
|
+
// resolves a second physical yjs (cou-sh's local file-link), a
|
|
163
|
+
// foreign Y.Map fails `instanceof` and would read `undefined` —
|
|
164
|
+
// disabling the guard in the very environment that needs it. `.get`
|
|
165
|
+
// / `.toJSON` work regardless of which yjs constructed the map.
|
|
166
|
+
let effectivePatch = patch;
|
|
167
|
+
if (
|
|
168
|
+
!opts.allowLabelClear &&
|
|
169
|
+
Object.hasOwn(patch, "label") &&
|
|
170
|
+
isPlaceholderLabel(patch.label)
|
|
171
|
+
) {
|
|
172
|
+
let existingLabel: unknown;
|
|
173
|
+
if (raw != null && typeof (raw as { get?: unknown }).get === "function") {
|
|
174
|
+
existingLabel = (raw as Y.Map<unknown>).get("label");
|
|
175
|
+
} else if (
|
|
176
|
+
raw != null &&
|
|
177
|
+
typeof (raw as { toJSON?: unknown }).toJSON === "function"
|
|
178
|
+
) {
|
|
179
|
+
existingLabel = ((raw as { toJSON: () => Record<string, unknown> }).toJSON())
|
|
180
|
+
?.label;
|
|
181
|
+
} else if (raw != null && typeof raw === "object") {
|
|
182
|
+
existingLabel = (raw as Record<string, unknown>).label;
|
|
183
|
+
}
|
|
184
|
+
if (!isPlaceholderLabel(existingLabel)) {
|
|
185
|
+
const { label: _dropped, ...rest } = patch;
|
|
186
|
+
effectivePatch = rest;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
131
190
|
if (raw instanceof Y.Map) {
|
|
132
|
-
for (const [k, v] of Object.entries(
|
|
191
|
+
for (const [k, v] of Object.entries(effectivePatch)) {
|
|
133
192
|
if (v === undefined) raw.delete(k);
|
|
134
193
|
else raw.set(k, v);
|
|
135
194
|
}
|
|
@@ -138,8 +197,8 @@ export function patchEntry(
|
|
|
138
197
|
}
|
|
139
198
|
const base =
|
|
140
199
|
raw == null ? {} : (toPlain(raw) as Record<string, unknown>);
|
|
141
|
-
const merged: Record<string, unknown> = { ...base, ...
|
|
142
|
-
for (const [k, v] of Object.entries(
|
|
200
|
+
const merged: Record<string, unknown> = { ...base, ...effectivePatch };
|
|
201
|
+
for (const [k, v] of Object.entries(effectivePatch)) {
|
|
143
202
|
if (v === undefined) delete merged[k];
|
|
144
203
|
}
|
|
145
204
|
for (const k of removeKeys) delete merged[k];
|