@sanity/sdk 2.18.0 → 2.20.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/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +196 -22
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/authStore-CWTAuieY.js +1072 -0
- package/dist/authStore-CWTAuieY.js.map +1 -0
- package/dist/clientStore-CMzJ7XhZ.js +168 -0
- package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
- package/dist/getEnv-76AjZd_f.js +178 -0
- package/dist/getEnv-76AjZd_f.js.map +1 -0
- package/dist/index.d.ts +2 -1921
- package/dist/index.js +4165 -3711
- package/dist/index.js.map +1 -1
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/telemetryManager-Bd3O8z2S.js +127 -0
- package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
- package/dist/utils-BMwA8Kg9.d.ts +3334 -0
- package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
- package/dist/version-DL2l07HG.js +9 -0
- package/dist/version-DL2l07HG.js.map +1 -0
- package/package.json +13 -21
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/index.ts +37 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/document/documentConstants.ts +8 -0
- package/src/document/documentStore.concurrency.test.ts +313 -22
- package/src/document/documentStore.test.ts +98 -1
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +133 -0
- package/src/document/reducers.ts +18 -3
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/projection/projectionStore.test.ts +0 -8
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -926
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -133
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1586
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
|
@@ -565,6 +565,33 @@ describe('diffMatchPatch', () => {
|
|
|
565
565
|
expect(output).toEqual(input)
|
|
566
566
|
})
|
|
567
567
|
|
|
568
|
+
it('applies the patch when its offsets miss a multi-byte boundary', () => {
|
|
569
|
+
// `\u00f8` is two bytes, so this patch's offsets land inside it and the byte
|
|
570
|
+
// walk overshoots. The default is to throw, which escapes the rebase loop in
|
|
571
|
+
// `reducers.ts` and kills the whole outgoing-actions pipeline: the instance
|
|
572
|
+
// then silently stops writing every document. The patch itself is fine and
|
|
573
|
+
// still applies.
|
|
574
|
+
const input = {foo: 'abcdefg\u00f8'}
|
|
575
|
+
const patch = '@@ -9,1 +9,2 @@\n \u00f8\n+!\n'
|
|
576
|
+
|
|
577
|
+
expect(() => diffMatchPatch(input, {foo: patch})).not.toThrow()
|
|
578
|
+
expect(diffMatchPatch(input, {foo: patch})).toEqual({foo: 'abcdefg\u00f8!'})
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('still patches the other properties in the same mutation', () => {
|
|
582
|
+
const input = {multibyte: 'abcdefg\u00f8', ascii: 'the quick brown fox'}
|
|
583
|
+
|
|
584
|
+
const output = diffMatchPatch(input, {
|
|
585
|
+
multibyte: '@@ -9,1 +9,2 @@\n \u00f8\n+!\n',
|
|
586
|
+
ascii: '@@ -13,7 +13,7 @@\n own \n-fox\n+cat\n',
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
expect(output).toEqual({
|
|
590
|
+
multibyte: 'abcdefg\u00f8!',
|
|
591
|
+
ascii: 'the quick brown cat',
|
|
592
|
+
})
|
|
593
|
+
})
|
|
594
|
+
|
|
568
595
|
it('applies a diff-match-patch that makes no changes', () => {
|
|
569
596
|
const input = {foo: 'unchanged'}
|
|
570
597
|
const patch = '@@ -1,9 +1,9 @@\n unchanged\n'
|
|
@@ -617,7 +617,16 @@ export function diffMatchPatch(
|
|
|
617
617
|
)
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
-
|
|
620
|
+
// Offsets arrive as UTF-8 byte positions and can overshoot when they
|
|
621
|
+
// land inside a multi-byte character, which happens once the string a
|
|
622
|
+
// patch was computed against has diverged. The default is to throw, and
|
|
623
|
+
// that throw escapes the rebase loop in `reducers.ts`, kills the
|
|
624
|
+
// outgoing-actions pipeline, and leaves the instance silently dropping
|
|
625
|
+
// every write. Clamping to the nearest index applies the patch instead,
|
|
626
|
+
// which is what the Studio and the Portable Text editor already do.
|
|
627
|
+
const [nextValue] = applyPatches(parsePatch(dmp), value, {
|
|
628
|
+
allowExceedingIndices: true,
|
|
629
|
+
})
|
|
621
630
|
return {path, value: nextValue}
|
|
622
631
|
})
|
|
623
632
|
.reduce((acc, {path, value}) => setDeep(acc, path, value), input)
|
|
@@ -266,7 +266,9 @@ describe('calculatePermissions', () => {
|
|
|
266
266
|
_createdAt: '2025-01-01T00:00:00.000Z',
|
|
267
267
|
_updatedAt: '2025-01-01T00:00:00.000Z',
|
|
268
268
|
_rev: 'rev-1',
|
|
269
|
-
_system
|
|
269
|
+
// Content Lake sets `_system.createdBy` on canvas documents; the public
|
|
270
|
+
// `DocumentSystem` type doesn't declare it, so the literal needs a cast.
|
|
271
|
+
_system: {createdBy} as SanityDocument['_system'],
|
|
270
272
|
})
|
|
271
273
|
|
|
272
274
|
const editAction: DocumentAction = {
|
|
@@ -632,6 +632,60 @@ describe('revertOutgoingTransaction', () => {
|
|
|
632
632
|
const docState = newState.documentStates[draftId]
|
|
633
633
|
expect(docState?.unverifiedRevisions && docState.unverifiedRevisions['txnOut']).toBeUndefined()
|
|
634
634
|
})
|
|
635
|
+
|
|
636
|
+
it('unregisters the reverted batch subscription ids, making documents with no other subscribers evictable', () => {
|
|
637
|
+
const draftId = getDraftId(DocumentId('doc1'))
|
|
638
|
+
const pubId = getPublishedId(DocumentId('doc1'))
|
|
639
|
+
const state: SyncTransactionState = {
|
|
640
|
+
queued: [],
|
|
641
|
+
applied: [],
|
|
642
|
+
outgoing: {
|
|
643
|
+
transactionId: 'txnRevert',
|
|
644
|
+
actions: [
|
|
645
|
+
{
|
|
646
|
+
type: 'document.edit',
|
|
647
|
+
documentId: 'doc1',
|
|
648
|
+
documentType: 'book',
|
|
649
|
+
patches: [{set: {foo: 'changed'}}],
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
disableBatching: false,
|
|
653
|
+
batchedTransactionIds: ['txnRevert'],
|
|
654
|
+
outgoingActions: [],
|
|
655
|
+
outgoingMutations: [],
|
|
656
|
+
base: {},
|
|
657
|
+
working: {},
|
|
658
|
+
previous: {},
|
|
659
|
+
previousRevs: {},
|
|
660
|
+
timestamp: '2025-02-06T00:04:30.000Z',
|
|
661
|
+
},
|
|
662
|
+
grants,
|
|
663
|
+
documentStates: {
|
|
664
|
+
// only subscriber is the reverted transaction itself
|
|
665
|
+
[draftId]: {
|
|
666
|
+
id: draftId,
|
|
667
|
+
subscriptions: ['txnRevert'],
|
|
668
|
+
local: {...exampleDoc, _id: draftId, foo: 'changed', _rev: 'rev2'},
|
|
669
|
+
remote: {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'},
|
|
670
|
+
},
|
|
671
|
+
// also has a UI subscriber, so it should survive with only the
|
|
672
|
+
// transaction subscription removed
|
|
673
|
+
[pubId]: {
|
|
674
|
+
id: pubId,
|
|
675
|
+
subscriptions: ['txnRevert', 'sub-ui'],
|
|
676
|
+
local: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
|
|
677
|
+
remote: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
}
|
|
681
|
+
const newState = revertOutgoingTransaction(state)
|
|
682
|
+
expect(newState.outgoing).toBeUndefined()
|
|
683
|
+
// no subscribers remain, so the document state is evicted entirely
|
|
684
|
+
expect(newState.documentStates[draftId]).toBeUndefined()
|
|
685
|
+
// the UI subscription keeps the other document alive, but the reverted
|
|
686
|
+
// transaction id is gone from its subscriptions
|
|
687
|
+
expect(newState.documentStates[pubId]?.subscriptions).toEqual(['sub-ui'])
|
|
688
|
+
})
|
|
635
689
|
})
|
|
636
690
|
|
|
637
691
|
describe('applyRemoteDocument', () => {
|
|
@@ -1124,6 +1178,85 @@ describe('applyRemoteDocument', () => {
|
|
|
1124
1178
|
})
|
|
1125
1179
|
})
|
|
1126
1180
|
|
|
1181
|
+
describe('rebase over multi-byte text', () => {
|
|
1182
|
+
it('does not kill the pipeline when a plain edit rebases onto multi-byte text', () => {
|
|
1183
|
+
// The reported production failure: a byte-offset throw from
|
|
1184
|
+
// `@sanity/diff-match-patch` is a plain `Error`, and `createMutationApplier`
|
|
1185
|
+
// only converts to `ActionError` when `preserveOperations` is set. So for an
|
|
1186
|
+
// ordinary `editDocument` it escapes this rebase loop, reaches the rxjs
|
|
1187
|
+
// subscription, and the outgoing-actions pipeline for the whole instance
|
|
1188
|
+
// dies. Every later write is then dropped silently, for every document.
|
|
1189
|
+
const docId = getDraftId(DocumentId('doc1'))
|
|
1190
|
+
const originalDoc: SanityDocument = {
|
|
1191
|
+
...exampleDoc,
|
|
1192
|
+
_id: docId,
|
|
1193
|
+
_rev: 'rev1',
|
|
1194
|
+
title: 'abcdefg\u00f8',
|
|
1195
|
+
}
|
|
1196
|
+
const locallyEditedDoc: SanityDocument = {
|
|
1197
|
+
...originalDoc,
|
|
1198
|
+
title: 'abcdefg\u00f8!',
|
|
1199
|
+
_rev: 'txnLocal',
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const appliedTransaction: AppliedTransaction = {
|
|
1203
|
+
transactionId: 'txnLocal',
|
|
1204
|
+
actions: [
|
|
1205
|
+
{
|
|
1206
|
+
type: 'document.edit',
|
|
1207
|
+
documentId: 'doc1',
|
|
1208
|
+
documentType: 'author',
|
|
1209
|
+
patches: [{diffMatchPatch: {title: '@@ -9,1 +9,2 @@\n \u00f8\n+!\n'}}],
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
previous: {[docId]: originalDoc},
|
|
1213
|
+
base: {[docId]: originalDoc},
|
|
1214
|
+
working: {[docId]: locallyEditedDoc},
|
|
1215
|
+
previousRevs: {[docId]: 'rev1'},
|
|
1216
|
+
timestamp: '2025-02-06T00:16:00.000Z',
|
|
1217
|
+
outgoingActions: [],
|
|
1218
|
+
outgoingMutations: [],
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const initialState: SyncTransactionState = {
|
|
1222
|
+
queued: [],
|
|
1223
|
+
applied: [appliedTransaction],
|
|
1224
|
+
outgoing: undefined,
|
|
1225
|
+
grants,
|
|
1226
|
+
documentStates: {
|
|
1227
|
+
[docId]: {
|
|
1228
|
+
id: docId,
|
|
1229
|
+
subscriptions: ['sub1'],
|
|
1230
|
+
local: locallyEditedDoc,
|
|
1231
|
+
remote: originalDoc,
|
|
1232
|
+
unverifiedRevisions: {},
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// someone else changed the same field, so the local patch is rebased onto
|
|
1238
|
+
// a base its byte offsets were not computed against
|
|
1239
|
+
const remoteDoc: SanityDocument = {
|
|
1240
|
+
...originalDoc,
|
|
1241
|
+
title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8',
|
|
1242
|
+
_rev: 'txnForeign',
|
|
1243
|
+
}
|
|
1244
|
+
const remote: RemoteDocument = {
|
|
1245
|
+
type: 'mutation',
|
|
1246
|
+
documentId: docId,
|
|
1247
|
+
document: remoteDoc,
|
|
1248
|
+
revision: 'txnForeign',
|
|
1249
|
+
previousRev: 'rev1',
|
|
1250
|
+
timestamp: '2025-02-06T00:17:00.000Z',
|
|
1251
|
+
mutations: [{patch: {id: docId, set: {title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8'}}}],
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const events = new Subject<DocumentEvent>()
|
|
1255
|
+
|
|
1256
|
+
expect(() => applyRemoteDocument(initialState, remote, events)).not.toThrow()
|
|
1257
|
+
})
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1127
1260
|
describe('rebase with preserved operations', () => {
|
|
1128
1261
|
it('skips a preserved-operations transaction that fails to re-apply and emits rebase-error', () => {
|
|
1129
1262
|
const docId = getDraftId(DocumentId('doc1'))
|
package/src/document/reducers.ts
CHANGED
|
@@ -406,8 +406,23 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
|
|
|
406
406
|
|
|
407
407
|
export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTransactionState {
|
|
408
408
|
if (!prev.grants) return prev
|
|
409
|
+
|
|
410
|
+
// unregister the reverted batch's transaction-held subscriptions, symmetric
|
|
411
|
+
// with `cleanupOutgoingTransaction` on ack and `removeQueuedTransaction` on
|
|
412
|
+
// apply failure. without this, a reverted document's subscription IDs are
|
|
413
|
+
// never released and it can never be evicted
|
|
414
|
+
let base = prev
|
|
415
|
+
if (prev.outgoing) {
|
|
416
|
+
const ids = getDocumentIdsFromHandleLikes(prev.outgoing.actions)
|
|
417
|
+
for (const transactionId of prev.outgoing.batchedTransactionIds) {
|
|
418
|
+
for (const documentId of ids) {
|
|
419
|
+
base = removeSubscriptionIdFromDocument(base, documentId, transactionId)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
409
424
|
let working = Object.fromEntries(
|
|
410
|
-
Object.entries(
|
|
425
|
+
Object.entries(base.documentStates).map(([documentId, documentState]) => [
|
|
411
426
|
documentId,
|
|
412
427
|
documentState?.remote,
|
|
413
428
|
]),
|
|
@@ -428,11 +443,11 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
|
|
|
428
443
|
}
|
|
429
444
|
|
|
430
445
|
return {
|
|
431
|
-
...
|
|
446
|
+
...base,
|
|
432
447
|
applied: nextApplied,
|
|
433
448
|
outgoing: undefined,
|
|
434
449
|
documentStates: Object.fromEntries(
|
|
435
|
-
Object.entries(
|
|
450
|
+
Object.entries(base.documentStates)
|
|
436
451
|
.filter((e): e is [string, DocumentState] => !!e[1])
|
|
437
452
|
.map(([documentId, {unverifiedRevisions = {}, local, ...documentState}]) => {
|
|
438
453
|
const next: DocumentState = {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {getEditingDocumentId, getEffectiveDocumentId} from './util'
|
|
4
|
+
|
|
5
|
+
const PUBLISHED = 'movie-1'
|
|
6
|
+
const DRAFT = 'drafts.movie-1'
|
|
7
|
+
const VERSION = 'versions.autumn.movie-1'
|
|
8
|
+
|
|
9
|
+
describe('getEditingDocumentId', () => {
|
|
10
|
+
it('resolves the draft by default, which is what the Studio edits', () => {
|
|
11
|
+
expect(getEditingDocumentId({documentId: PUBLISHED})).toBe(DRAFT)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("resolves the draft under the 'drafts' perspective", () => {
|
|
15
|
+
expect(getEditingDocumentId({documentId: PUBLISHED, perspective: 'drafts'})).toBe(DRAFT)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("resolves the published document under the 'published' perspective", () => {
|
|
19
|
+
expect(getEditingDocumentId({documentId: PUBLISHED, perspective: 'published'})).toBe(PUBLISHED)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('resolves the version under a release perspective', () => {
|
|
23
|
+
expect(
|
|
24
|
+
getEditingDocumentId({documentId: PUBLISHED, perspective: {releaseName: 'autumn'}}),
|
|
25
|
+
).toBe(VERSION)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('resolves the published document for live edit, which has no draft', () => {
|
|
29
|
+
expect(getEditingDocumentId({documentId: PUBLISHED, liveEdit: true})).toBe(PUBLISHED)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is idempotent when handed an already-specific id', () => {
|
|
33
|
+
expect(getEditingDocumentId({documentId: DRAFT})).toBe(DRAFT)
|
|
34
|
+
expect(getEditingDocumentId({documentId: VERSION, perspective: 'published'})).toBe(PUBLISHED)
|
|
35
|
+
expect(getEditingDocumentId({documentId: DRAFT, perspective: {releaseName: 'autumn'}})).toBe(
|
|
36
|
+
VERSION,
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('differs from getEffectiveDocumentId precisely where it has to', () => {
|
|
41
|
+
// The canonical key collapses a draft onto its published id, which is right for
|
|
42
|
+
// storing state and wrong for anything comparing exact ids.
|
|
43
|
+
expect(getEffectiveDocumentId({documentId: DRAFT, documentType: 'movie'})).toBe(PUBLISHED)
|
|
44
|
+
expect(getEditingDocumentId({documentId: DRAFT})).toBe(DRAFT)
|
|
45
|
+
})
|
|
46
|
+
})
|
package/src/document/util.ts
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
|
-
import {DocumentId, getPublishedId, getVersionId} from '@sanity/id-utils'
|
|
1
|
+
import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
|
|
2
2
|
|
|
3
3
|
import {type DocumentHandle} from '../config/sanityConfig'
|
|
4
4
|
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The id of the specific document a user is editing under a given perspective:
|
|
8
|
+
* the draft, the published document, or a release version.
|
|
9
|
+
*
|
|
10
|
+
* Distinct from {@link getEffectiveDocumentId}, which resolves the canonical key a
|
|
11
|
+
* document's state is stored under and so collapses a draft onto its published id.
|
|
12
|
+
* This one keeps them apart, because some things compare the exact id. Presence is
|
|
13
|
+
* the motivating case: the Studio's field indicators match the id its form is on,
|
|
14
|
+
* exactly, so an app reporting the published id shows up in the Studio at document
|
|
15
|
+
* level while never lighting up a single field.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* Resolved from the handle alone, which is right whenever a draft or version
|
|
19
|
+
* exists, so for any document actually being edited. The Studio additionally falls
|
|
20
|
+
* back to whichever document exists, so for a published document nobody has edited
|
|
21
|
+
* yet its form sits on the published id where this returns the draft id. Matching
|
|
22
|
+
* that would mean reading document state, which presence deliberately does not
|
|
23
|
+
* depend on.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
export function getEditingDocumentId(
|
|
28
|
+
doc: Pick<DocumentHandle, 'documentId' | 'liveEdit' | 'perspective'>,
|
|
29
|
+
): string {
|
|
30
|
+
if (doc.liveEdit) {
|
|
31
|
+
return getPublishedId(DocumentId(doc.documentId))
|
|
32
|
+
}
|
|
33
|
+
if (isReleasePerspective(doc.perspective)) {
|
|
34
|
+
return getVersionId(DocumentId(doc.documentId), doc.perspective.releaseName)
|
|
35
|
+
}
|
|
36
|
+
if (doc.perspective === 'published') {
|
|
37
|
+
return getPublishedId(DocumentId(doc.documentId))
|
|
38
|
+
}
|
|
39
|
+
return getDraftId(DocumentId(doc.documentId))
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
export function getEffectiveDocumentId(doc: DocumentHandle): string {
|
|
7
43
|
if (doc.liveEdit) {
|
|
8
44
|
return doc.documentId
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {fromUrl} from '@sanity/bifur-client'
|
|
2
2
|
import {type SanityClient} from '@sanity/client'
|
|
3
|
-
import {of, Subject} from 'rxjs'
|
|
4
|
-
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
3
|
+
import {type Observable, of, Subject} from 'rxjs'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
5
5
|
|
|
6
6
|
import {createBifurTransport} from './bifurTransport'
|
|
7
7
|
import {type PresenceLocation, type TransportEvent} from './types'
|
|
@@ -39,16 +39,20 @@ describe('createBifurTransport', () => {
|
|
|
39
39
|
let mockBifurClient: {
|
|
40
40
|
listen: Mock
|
|
41
41
|
request: Mock
|
|
42
|
+
heartbeats: Observable<Date>
|
|
42
43
|
}
|
|
43
44
|
let mockSanityClient: SanityClient
|
|
44
45
|
let token$: Subject<string | null>
|
|
46
|
+
let heartbeats$: Subject<Date>
|
|
45
47
|
|
|
46
48
|
beforeEach(() => {
|
|
47
49
|
vi.useFakeTimers()
|
|
48
50
|
vi.clearAllMocks()
|
|
51
|
+
heartbeats$ = new Subject<Date>()
|
|
49
52
|
mockBifurClient = {
|
|
50
53
|
listen: vi.fn(() => new Subject<never>()),
|
|
51
54
|
request: vi.fn(() => of(undefined)),
|
|
55
|
+
heartbeats: heartbeats$.asObservable(),
|
|
52
56
|
}
|
|
53
57
|
fromUrlMock.mockReturnValue(mockBifurClient)
|
|
54
58
|
|
|
@@ -130,6 +134,8 @@ describe('createBifurTransport', () => {
|
|
|
130
134
|
|
|
131
135
|
const receivedEvents: TransportEvent[] = []
|
|
132
136
|
incomingEvents$.subscribe((event) => receivedEvents.push(event))
|
|
137
|
+
// The listener is established per live connection, so bring one up first.
|
|
138
|
+
heartbeats$.next(new Date())
|
|
133
139
|
|
|
134
140
|
incomingBifurEvents$.next({
|
|
135
141
|
type: 'rollCall',
|
|
@@ -161,6 +167,8 @@ describe('createBifurTransport', () => {
|
|
|
161
167
|
|
|
162
168
|
const receivedEvents: TransportEvent[] = []
|
|
163
169
|
incomingEvents$.subscribe((event) => receivedEvents.push(event))
|
|
170
|
+
// The listener is established per live connection, so bring one up first.
|
|
171
|
+
heartbeats$.next(new Date())
|
|
164
172
|
|
|
165
173
|
const locations: PresenceLocation[] = [
|
|
166
174
|
{type: 'document', documentId: 'doc1', path: ['a'], lastActiveAt: new Date().toISOString()},
|
|
@@ -200,6 +208,8 @@ describe('createBifurTransport', () => {
|
|
|
200
208
|
|
|
201
209
|
const receivedEvents: TransportEvent[] = []
|
|
202
210
|
incomingEvents$.subscribe((event) => receivedEvents.push(event))
|
|
211
|
+
// The listener is established per live connection, so bring one up first.
|
|
212
|
+
heartbeats$.next(new Date())
|
|
203
213
|
|
|
204
214
|
incomingBifurEvents$.next({
|
|
205
215
|
type: 'disconnect',
|
|
@@ -233,6 +243,7 @@ describe('createBifurTransport', () => {
|
|
|
233
243
|
incomingEvents$.subscribe({
|
|
234
244
|
error: (err) => errors.push(err),
|
|
235
245
|
})
|
|
246
|
+
heartbeats$.next(new Date())
|
|
236
247
|
|
|
237
248
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
238
249
|
incomingBifurEvents$.next({type: 'unknown'} as any)
|
|
@@ -242,7 +253,148 @@ describe('createBifurTransport', () => {
|
|
|
242
253
|
expect(errors[0].message).toContain('Got unknown presence event')
|
|
243
254
|
})
|
|
244
255
|
|
|
245
|
-
describe('
|
|
256
|
+
describe('connections', () => {
|
|
257
|
+
it('emits an incrementing generation each time the socket becomes live', () => {
|
|
258
|
+
const [, , connections$] = createBifurTransport({
|
|
259
|
+
client: mockSanityClient,
|
|
260
|
+
token$,
|
|
261
|
+
sessionId: 'my-session',
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const generations: number[] = []
|
|
265
|
+
connections$.subscribe((generation) => generations.push(generation))
|
|
266
|
+
|
|
267
|
+
// Several heartbeats on one live socket are a single generation.
|
|
268
|
+
heartbeats$.next(new Date())
|
|
269
|
+
heartbeats$.next(new Date())
|
|
270
|
+
expect(generations).toEqual([1])
|
|
271
|
+
|
|
272
|
+
// bifur-client errors the stream when the socket closes. The transport is
|
|
273
|
+
// expected to reconnect rather than give up for the life of the page.
|
|
274
|
+
const reconnected$ = new Subject<Date>()
|
|
275
|
+
mockBifurClient.heartbeats = reconnected$.asObservable()
|
|
276
|
+
heartbeats$.error(new Error('WebSocket connection error'))
|
|
277
|
+
|
|
278
|
+
// First retry is scheduled 200ms out (2 ** 1 * 100).
|
|
279
|
+
vi.advanceTimersByTime(200)
|
|
280
|
+
reconnected$.next(new Date())
|
|
281
|
+
|
|
282
|
+
expect(generations).toEqual([1, 2])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('does not resubscribe before the backoff has elapsed', () => {
|
|
286
|
+
const [, , connections$] = createBifurTransport({
|
|
287
|
+
client: mockSanityClient,
|
|
288
|
+
token$,
|
|
289
|
+
sessionId: 'my-session',
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const generations: number[] = []
|
|
293
|
+
connections$.subscribe((generation) => generations.push(generation))
|
|
294
|
+
heartbeats$.next(new Date())
|
|
295
|
+
|
|
296
|
+
const reconnected$ = new Subject<Date>()
|
|
297
|
+
mockBifurClient.heartbeats = reconnected$.asObservable()
|
|
298
|
+
heartbeats$.error(new Error('WebSocket connection error'))
|
|
299
|
+
|
|
300
|
+
vi.advanceTimersByTime(199)
|
|
301
|
+
reconnected$.next(new Date())
|
|
302
|
+
expect(generations).toEqual([1])
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
describe('incoming events across a reconnect', () => {
|
|
307
|
+
it('keeps delivering presence after the socket drops', () => {
|
|
308
|
+
// bifur errors every stream on the same socket close, the inbound listener
|
|
309
|
+
// included. If only the heartbeat stream recovers, this client re-announces
|
|
310
|
+
// itself to peers while permanently ceasing to see any of them.
|
|
311
|
+
const firstListen$ = new Subject<IncomingBifurEvent>()
|
|
312
|
+
const secondListen$ = new Subject<IncomingBifurEvent>()
|
|
313
|
+
mockBifurClient.listen.mockReturnValueOnce(firstListen$).mockReturnValueOnce(secondListen$)
|
|
314
|
+
|
|
315
|
+
const [incomingEvents$, , connections$] = createBifurTransport({
|
|
316
|
+
client: mockSanityClient,
|
|
317
|
+
token$,
|
|
318
|
+
sessionId: 'my-session',
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const received: TransportEvent[] = []
|
|
322
|
+
incomingEvents$.subscribe({
|
|
323
|
+
next: (event) => received.push(event),
|
|
324
|
+
error: () => received.push({type: 'rollCall', userId: 'STREAM_DIED', sessionId: ''}),
|
|
325
|
+
})
|
|
326
|
+
connections$.subscribe()
|
|
327
|
+
|
|
328
|
+
heartbeats$.next(new Date())
|
|
329
|
+
firstListen$.next({type: 'rollCall', i: 'user-1', session: 'before'})
|
|
330
|
+
expect(received.map((e) => e.sessionId)).toEqual(['before'])
|
|
331
|
+
|
|
332
|
+
// Socket closes: both streams error.
|
|
333
|
+
const reconnectedHeartbeats$ = new Subject<Date>()
|
|
334
|
+
mockBifurClient.heartbeats = reconnectedHeartbeats$.asObservable()
|
|
335
|
+
firstListen$.error(new Error('WebSocket connection error'))
|
|
336
|
+
heartbeats$.error(new Error('WebSocket connection error'))
|
|
337
|
+
|
|
338
|
+
vi.advanceTimersByTime(200)
|
|
339
|
+
reconnectedHeartbeats$.next(new Date())
|
|
340
|
+
|
|
341
|
+
// Peers answer the roll call we send on reconnect. We have to be listening.
|
|
342
|
+
secondListen$.next({type: 'rollCall', i: 'user-1', session: 'after'})
|
|
343
|
+
|
|
344
|
+
expect(received.map((e) => e.sessionId)).toEqual(['before', 'after'])
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('unload', () => {
|
|
349
|
+
afterEach(() => {
|
|
350
|
+
vi.unstubAllGlobals()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('emits on beforeunload and pagehide, and removes its listeners', () => {
|
|
354
|
+
// These tests run in Node, so stand in a bare EventTarget for `window`.
|
|
355
|
+
const fakeWindow = new EventTarget()
|
|
356
|
+
vi.stubGlobal('window', fakeWindow)
|
|
357
|
+
|
|
358
|
+
const [, , , unload$] = createBifurTransport({
|
|
359
|
+
client: mockSanityClient,
|
|
360
|
+
token$,
|
|
361
|
+
sessionId: 'my-session',
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
let emissions = 0
|
|
365
|
+
const subscription = unload$.subscribe(() => {
|
|
366
|
+
emissions += 1
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
fakeWindow.dispatchEvent(new Event('beforeunload'))
|
|
370
|
+
expect(emissions).toBe(1)
|
|
371
|
+
|
|
372
|
+
// `pagehide` matters on its own: iOS Safari and pages entering the
|
|
373
|
+
// back/forward cache never fire `beforeunload`.
|
|
374
|
+
fakeWindow.dispatchEvent(new Event('pagehide'))
|
|
375
|
+
expect(emissions).toBe(2)
|
|
376
|
+
|
|
377
|
+
// The listeners belong to the subscription, so unsubscribing removes them
|
|
378
|
+
// instead of leaking one per transport for the life of the page.
|
|
379
|
+
subscription.unsubscribe()
|
|
380
|
+
fakeWindow.dispatchEvent(new Event('beforeunload'))
|
|
381
|
+
expect(emissions).toBe(2)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('is inert when there is no window', () => {
|
|
385
|
+
const [, , , unload$] = createBifurTransport({
|
|
386
|
+
client: mockSanityClient,
|
|
387
|
+
token$,
|
|
388
|
+
sessionId: 'my-session',
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
let completed = false
|
|
392
|
+
unload$.subscribe({complete: () => (completed = true)})
|
|
393
|
+
expect(completed).toBe(true)
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
describe('dispatch', () => {
|
|
246
398
|
it('sends a "rollCall" message', () => {
|
|
247
399
|
const [, dispatchMessage] = createBifurTransport({
|
|
248
400
|
client: mockSanityClient,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {type BifurClient, fromUrl} from '@sanity/bifur-client'
|
|
2
2
|
import {type SanityClient} from '@sanity/client'
|
|
3
|
-
import {EMPTY, fromEvent, type Observable} from 'rxjs'
|
|
4
|
-
import {map, share, switchMap} from 'rxjs/operators'
|
|
3
|
+
import {defer, EMPTY, fromEvent, merge, type Observable, timer} from 'rxjs'
|
|
4
|
+
import {catchError, distinctUntilChanged, map, retry, share, switchMap} from 'rxjs/operators'
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
type BifurTransportOptions,
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
type TransportMessage,
|
|
12
12
|
} from './types'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Longest gap between reconnection attempts, matching the Studio's presence
|
|
16
|
+
* transport so both behave the same way on a flaky connection.
|
|
17
|
+
*/
|
|
18
|
+
const CONNECT_RETRY_MAX_DELAY = 1000 * 240
|
|
19
|
+
|
|
14
20
|
type BifurStateMessage = {
|
|
15
21
|
type: 'state'
|
|
16
22
|
i: string
|
|
@@ -91,13 +97,80 @@ const handleIncomingMessage = (event: IncomingBifurEvent): TransportEvent => {
|
|
|
91
97
|
}
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Emits an incrementing generation number each time the socket becomes live.
|
|
102
|
+
*
|
|
103
|
+
* `@sanity/bifur-client` does not reconnect on its own — it errors the stream
|
|
104
|
+
* when the socket closes. Without the `retry` below, presence stops for the
|
|
105
|
+
* life of the page after the first dropped connection.
|
|
106
|
+
*/
|
|
107
|
+
const createConnections = (bifur: BifurClient): Observable<number> => {
|
|
108
|
+
let generation = 0
|
|
109
|
+
|
|
110
|
+
return defer(() => {
|
|
111
|
+
const current = ++generation
|
|
112
|
+
// `heartbeats` emits as soon as the socket is open and authorized, then on
|
|
113
|
+
// every server heartbeat and every response. Subscribing to it is also what
|
|
114
|
+
// keeps the socket alive between requests.
|
|
115
|
+
return bifur.heartbeats.pipe(map(() => current))
|
|
116
|
+
}).pipe(
|
|
117
|
+
retry({
|
|
118
|
+
delay: (_error, retryCount) =>
|
|
119
|
+
timer(Math.min(CONNECT_RETRY_MAX_DELAY, 2 ** retryCount * 100)),
|
|
120
|
+
resetOnSuccess: true,
|
|
121
|
+
}),
|
|
122
|
+
// After `retry` so that one operator instance spans reconnects, making each
|
|
123
|
+
// live socket yield exactly one emission.
|
|
124
|
+
distinctUntilChanged(),
|
|
125
|
+
share(),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Presence events from everyone else in the room, re-established on every live
|
|
131
|
+
* connection.
|
|
132
|
+
*
|
|
133
|
+
* A socket close errors this stream just as it errors the heartbeats, so without
|
|
134
|
+
* re-subscribing the client would keep announcing itself after a reconnect while
|
|
135
|
+
* never hearing from anyone again. Driving it from `connections$` rather than
|
|
136
|
+
* giving it its own `retry` keeps a single reconnect loop, instead of two
|
|
137
|
+
* independent backoffs racing over one refcounted socket.
|
|
138
|
+
*/
|
|
139
|
+
const createIncomingEvents = (
|
|
140
|
+
bifur: BifurClient,
|
|
141
|
+
connections$: Observable<number>,
|
|
142
|
+
): Observable<TransportEvent> =>
|
|
143
|
+
connections$.pipe(
|
|
144
|
+
switchMap(() =>
|
|
145
|
+
bifur.listen<IncomingBifurEvent>('presence').pipe(
|
|
146
|
+
// Let a dead listener go quietly. The next connection replaces it, and
|
|
147
|
+
// surfacing the error here would tear down the reconnect loop with it.
|
|
148
|
+
catchError(() => EMPTY),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
map(handleIncomingMessage),
|
|
152
|
+
share(),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Emits when the page is going away. `pagehide` is needed alongside
|
|
157
|
+
* `beforeunload` because iOS Safari and pages entering the back/forward cache
|
|
158
|
+
* never fire `beforeunload`.
|
|
159
|
+
*/
|
|
160
|
+
const createUnload = (): Observable<void> => {
|
|
161
|
+
if (typeof window === 'undefined') return EMPTY
|
|
162
|
+
|
|
163
|
+
return merge(fromEvent(window, 'beforeunload'), fromEvent(window, 'pagehide')).pipe(
|
|
164
|
+
map(() => undefined),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
94
168
|
export const createBifurTransport = (options: BifurTransportOptions): PresenceTransport => {
|
|
95
169
|
const {client, token$, sessionId} = options
|
|
96
170
|
const bifur = getBifurClient(client, token$)
|
|
97
171
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
.pipe(map(handleIncomingMessage))
|
|
172
|
+
const connections$ = createConnections(bifur)
|
|
173
|
+
const incomingEvents$ = createIncomingEvents(bifur, connections$)
|
|
101
174
|
|
|
102
175
|
const dispatchMessage = (message: TransportMessage): Observable<void> => {
|
|
103
176
|
switch (message.type) {
|
|
@@ -115,11 +188,5 @@ export const createBifurTransport = (options: BifurTransportOptions): PresenceTr
|
|
|
115
188
|
}
|
|
116
189
|
}
|
|
117
190
|
|
|
118
|
-
|
|
119
|
-
fromEvent(window, 'beforeunload')
|
|
120
|
-
.pipe(switchMap(() => dispatchMessage({type: 'disconnect'})))
|
|
121
|
-
.subscribe()
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return [incomingEvents$.pipe(share()), dispatchMessage]
|
|
191
|
+
return [incomingEvents$, dispatchMessage, connections$, createUnload()]
|
|
125
192
|
}
|