@sanity/sdk 2.20.1 → 2.20.2
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.js +1 -1
- package/dist/index.js +64 -17
- package/dist/index.js.map +1 -1
- package/dist/{telemetryManager-w-AMXtmz.js → telemetryManager-BmGqzj0b.js} +2 -2
- package/dist/{telemetryManager-w-AMXtmz.js.map → telemetryManager-BmGqzj0b.js.map} +1 -1
- package/dist/{version-ByqNiWrK.js → version-CPumgx0u.js} +2 -2
- package/dist/{version-ByqNiWrK.js.map → version-CPumgx0u.js.map} +1 -1
- package/package.json +1 -1
- package/src/document/documentConstants.ts +9 -0
- package/src/document/documentStore.test.ts +69 -0
- package/src/document/reducers.test.ts +224 -0
- package/src/document/reducers.ts +108 -25
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-vyAXikos.js";
|
|
2
2
|
import { r as createLogger } from "./getEnv-76AjZd_f.js";
|
|
3
|
-
import { t as CORE_SDK_VERSION } from "./version-
|
|
3
|
+
import { t as CORE_SDK_VERSION } from "./version-CPumgx0u.js";
|
|
4
4
|
import "@sanity/client";
|
|
5
5
|
import { createBatchedStore, defineEvent } from "@sanity/telemetry";
|
|
6
6
|
/** @internal */
|
|
@@ -124,4 +124,4 @@ function createTelemetryManager(options) {
|
|
|
124
124
|
}
|
|
125
125
|
export { telemetryManager_exports as t };
|
|
126
126
|
|
|
127
|
-
//# sourceMappingURL=telemetryManager-
|
|
127
|
+
//# sourceMappingURL=telemetryManager-BmGqzj0b.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetryManager-
|
|
1
|
+
{"version":3,"file":"telemetryManager-BmGqzj0b.js","names":[],"sources":["../src/telemetry/events.ts","../src/telemetry/telemetryManager.ts"],"sourcesContent":["import {defineEvent} from '@sanity/telemetry'\n\n/** @internal */\nexport const SDKSessionStarted = defineEvent<{\n version: string\n projectId: string\n perspective: string\n authMethod: string\n}>({\n name: 'SDK Session Started',\n version: 1,\n description: 'SDK instance created (environment is recorded in the event context)',\n})\n\n/** @internal */\nexport const SDKHookMounted = defineEvent<{\n hookName: string\n}>({\n name: 'SDK Hook Mounted',\n version: 1,\n description: 'An SDK hook was mounted for the first time in this session',\n})\n\n/** @internal */\nexport const SDKSessionEnded = defineEvent<{\n durationSeconds: number\n hooksUsed: string[]\n}>({\n name: 'SDK Session Ended',\n version: 1,\n description: 'SDK instance disposed (environment is recorded in the event context)',\n})\n\n/** @internal */\nexport const SDKError = defineEvent<{\n errorType: string\n hookName: string\n}>({\n name: 'SDK Error',\n version: 1,\n description: 'Runtime error caught in the SDK',\n})\n","import {type SanityClient} from '@sanity/client'\nimport {\n type ConsentStatus,\n createBatchedStore,\n type SessionId,\n type TelemetryEvent,\n type TelemetryLogger,\n type TelemetryStore,\n} from '@sanity/telemetry'\n\nimport {createLogger} from '../utils/logger'\nimport {CORE_SDK_VERSION} from '../version'\nimport {type TelemetryEnvironment} from './environment'\nimport {SDKError, SDKHookMounted, SDKSessionEnded, SDKSessionStarted} from './events'\n\nconst FLUSH_INTERVAL_MS = 30_000\nconst CONSENT_TAG = 'telemetry-consent.sdk'\nconst BATCH_TAG = 'telemetry.batch'\n\nconst log = createLogger('telemetry')\n\n/**\n * Manages telemetry for a single SDK instance.\n *\n * Wraps `@sanity/telemetry`'s batched store with SDK-specific concerns:\n * consent caching, session lifecycle events, and hook usage tracking.\n * The `environment` is captured at construction time and recorded in\n * the event context so downstream pipelines can distinguish dev and\n * production sessions.\n *\n * @internal\n */\nexport interface TelemetryManager {\n /**\n * Eagerly resolve and cache the user's consent status.\n * Returns true only when the user has explicitly opted in (`granted`).\n * Call this before logging any events to avoid buffering events that\n * will be dropped on the first flush.\n */\n checkConsent(): Promise<boolean>\n\n /** Log a \"SDK Session Started\" event */\n logSessionStarted(data: {projectId: string; perspective: string; authMethod: string}): void\n\n /** Log a \"SDK Hook Mounted\" event (deduplicated per hook name) */\n logHookFirstUsed(hookName: string): void\n\n /** Log a \"SDK Error\" event */\n logError(errorType: string, hookName: string): void\n\n /** Log a \"SDK Session Ended\" event and tear down the store */\n endSession(): void\n\n /** Tear down the store without logging a session-end event */\n dispose(): void\n\n /** The set of hook names used during this session */\n readonly hooksUsed: ReadonlySet<string>\n}\n\ninterface TelemetryManagerOptions {\n sessionId: string\n getClient: () => SanityClient\n projectId: string\n environment: TelemetryEnvironment\n}\n\n/**\n * Creates a telemetry manager for a single SDK instance session.\n *\n * The manager initializes a `createBatchedStore` from `@sanity/telemetry`,\n * caches the consent check for the lifetime of the session, and provides\n * typed methods for each SDK telemetry event.\n *\n * @internal\n */\nexport function createTelemetryManager(options: TelemetryManagerOptions): TelemetryManager {\n const {sessionId, getClient, projectId, environment} = options\n const startedAt = Date.now()\n const emittedHooks = new Set<string>()\n\n let cachedConsent: {status: ConsentStatus} | null = null\n\n const resolveConsent = async (): Promise<{status: ConsentStatus}> => {\n if (cachedConsent) return cachedConsent\n try {\n const client = getClient()\n const result = await client.request<{status: ConsentStatus}>({\n uri: '/intake/telemetry-status',\n tag: CONSENT_TAG,\n })\n cachedConsent = result\n } catch {\n cachedConsent = {status: 'undetermined'}\n }\n return cachedConsent\n }\n\n const enrichBatch = (batch: TelemetryEvent[]) =>\n batch.map((event) => {\n // Trace events (`trace.start` / `trace.log` / `trace.error` /\n // `trace.complete`) arrive with their own caller-provided `context`.\n // Log / userProperties events don't. Narrow via `in` so `event`\n // stays typed as `TelemetryEvent`, then merge so we don't drop the\n // trace context. SDK-owned fields below win on conflict.\n const existing =\n 'context' in event ? (event.context as Record<string, unknown> | undefined) : undefined\n return {\n ...event,\n context: {\n ...existing,\n version: CORE_SDK_VERSION,\n environment,\n origin: typeof window !== 'undefined' ? window.location.origin : 'node',\n },\n }\n })\n\n const sendEvents = async (batch: TelemetryEvent[]): Promise<unknown> => {\n const client = getClient()\n log.debug('sending event batch', {batchSize: batch.length, environment})\n return client.request({\n uri: '/intake/batch',\n method: 'POST',\n body: {projectId, batch: enrichBatch(batch)},\n tag: BATCH_TAG,\n })\n }\n\n const store: TelemetryStore<Record<string, unknown>> = createBatchedStore(\n sessionId as SessionId,\n {\n flushInterval: FLUSH_INTERVAL_MS,\n resolveConsent,\n sendEvents,\n },\n )\n\n const logger: TelemetryLogger<Record<string, unknown>> = store.logger\n\n return {\n async checkConsent() {\n const {status} = await resolveConsent()\n return status === 'granted'\n },\n\n logSessionStarted(data) {\n log.debug('event: SDK Session Started', {\n projectId: data.projectId,\n perspective: data.perspective,\n authMethod: data.authMethod,\n version: CORE_SDK_VERSION,\n environment,\n })\n logger.log(SDKSessionStarted, {\n version: CORE_SDK_VERSION,\n ...data,\n })\n },\n\n logHookFirstUsed(hookName: string) {\n if (emittedHooks.has(hookName)) return\n emittedHooks.add(hookName)\n log.debug('event: SDK Hook Mounted', {hookName})\n logger.log(SDKHookMounted, {hookName})\n },\n\n logError(errorType: string, hookName: string) {\n log.debug('event: SDK Error', {errorType, hookName})\n logger.log(SDKError, {errorType, hookName})\n },\n\n endSession() {\n const durationSeconds = Math.round((Date.now() - startedAt) / 1000)\n log.debug('event: SDK Session Ended', {\n durationSeconds,\n hooksUsed: [...emittedHooks],\n environment,\n })\n logger.log(SDKSessionEnded, {\n durationSeconds,\n hooksUsed: [...emittedHooks],\n })\n\n store.flush().catch(() => {\n // Best-effort flush on dispose; swallow errors\n })\n store.end()\n },\n\n dispose() {\n store.end()\n },\n\n get hooksUsed(): ReadonlySet<string> {\n return emittedHooks\n },\n }\n}\n"],"mappings":";;;;;;AAGA,MAAa,oBAAoB,YAK9B;CACD,MAAM;CACN,SAAS;CACT,aAAa;AACf,CAAC,GAGY,iBAAiB,YAE3B;CACD,MAAM;CACN,SAAS;CACT,aAAa;AACf,CAAC,GAGY,kBAAkB,YAG5B;CACD,MAAM;CACN,SAAS;CACT,aAAa;AACf,CAAC,GAGY,WAAW,YAGrB;CACD,MAAM;CACN,SAAS;CACT,aAAa;AACf,CAAC;;AC1BD,MAIM,MAAM,aAAa,WAAW;;;;;;;;;;AAyDpC,SAAgB,uBAAuB,SAAoD;CACzF,IAAM,EAAC,WAAW,WAAW,WAAW,gBAAe,SACjD,YAAY,KAAK,IAAI,GACrB,+BAAe,IAAI,IAAY,GAEjC,gBAAgD,MAE9C,iBAAiB,YAA8C;EACnE,IAAI,eAAe,OAAO;EAC1B,IAAI;GAMF,gBAAgB,MALD,UACW,CAAC,CAAC,QAAiC;IAC3D,KAAK;IACL,KAAK;GACP,CAAC;EAEH,QAAQ;GACN,gBAAgB,EAAC,QAAQ,eAAc;EACzC;EACA,OAAO;CACT,GAEM,eAAe,UACnB,MAAM,KAAK,UAAU;EAMnB,IAAM,WACJ,aAAa,QAAS,MAAM,UAAkD,KAAA;EAChF,OAAO;GACL,GAAG;GACH,SAAS;IACP,GAAG;IACH,SAAS;IACT;IACA,QAAQ,OAAO,SAAW,MAAc,OAAO,SAAS,SAAS;GACnE;EACF;CACF,CAAC,GAEG,aAAa,OAAO,UAA8C;EACtE,IAAM,SAAS,UAAU;EAEzB,OADA,IAAI,MAAM,uBAAuB;GAAC,WAAW,MAAM;GAAQ;EAAW,CAAC,GAChE,OAAO,QAAQ;GACpB,KAAK;GACL,QAAQ;GACR,MAAM;IAAC;IAAW,OAAO,YAAY,KAAK;GAAC;GAC3C,KAAK;EACP,CAAC;CACH,GAEM,QAAiD,mBACrD,WACA;EACE,eAAe;EACf;EACA;CACF,CACF,GAEM,SAAmD,MAAM;CAE/D,OAAO;EACL,MAAM,eAAe;GACnB,IAAM,EAAC,WAAU,MAAM,eAAe;GACtC,OAAO,WAAW;EACpB;EAEA,kBAAkB,MAAM;GAQtB,AAPA,IAAI,MAAM,8BAA8B;IACtC,WAAW,KAAK;IAChB,aAAa,KAAK;IAClB,YAAY,KAAK;IACjB,SAAS;IACT;GACF,CAAC,GACD,OAAO,IAAI,mBAAmB;IAC5B,SAAS;IACT,GAAG;GACL,CAAC;EACH;EAEA,iBAAiB,UAAkB;GAC7B,aAAa,IAAI,QAAQ,MAC7B,aAAa,IAAI,QAAQ,GACzB,IAAI,MAAM,2BAA2B,EAAC,SAAQ,CAAC,GAC/C,OAAO,IAAI,gBAAgB,EAAC,SAAQ,CAAC;EACvC;EAEA,SAAS,WAAmB,UAAkB;GAE5C,AADA,IAAI,MAAM,oBAAoB;IAAC;IAAW;GAAQ,CAAC,GACnD,OAAO,IAAI,UAAU;IAAC;IAAW;GAAQ,CAAC;EAC5C;EAEA,aAAa;GACX,IAAM,kBAAkB,KAAK,OAAO,KAAK,IAAI,IAAI,aAAa,GAAI;GAclE,AAbA,IAAI,MAAM,4BAA4B;IACpC;IACA,WAAW,CAAC,GAAG,YAAY;IAC3B;GACF,CAAC,GACD,OAAO,IAAI,iBAAiB;IAC1B;IACA,WAAW,CAAC,GAAG,YAAY;GAC7B,CAAC,GAED,MAAM,MAAM,CAAC,CAAC,YAAY,CAE1B,CAAC,GACD,MAAM,IAAI;EACZ;EAEA,UAAU;GACR,MAAM,IAAI;EACZ;EAEA,IAAI,YAAiC;GACnC,OAAO;EACT;CACF;AACF"}
|
|
@@ -3,7 +3,7 @@ import { t as getEnv } from "./getEnv-76AjZd_f.js";
|
|
|
3
3
|
* This version is provided by pkg-utils at build time
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
-
const CORE_SDK_VERSION = getEnv("PKG_VERSION") || "2.20.
|
|
6
|
+
const CORE_SDK_VERSION = getEnv("PKG_VERSION") || "2.20.2-development";
|
|
7
7
|
export { CORE_SDK_VERSION as t };
|
|
8
8
|
|
|
9
|
-
//# sourceMappingURL=version-
|
|
9
|
+
//# sourceMappingURL=version-CPumgx0u.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version-
|
|
1
|
+
{"version":3,"file":"version-CPumgx0u.js","names":[],"sources":["../package.json","../src/version.ts"],"sourcesContent":["","import {version} from '../package.json'\nimport {getEnv} from './utils/getEnv'\n\n/**\n * This version is provided by pkg-utils at build time\n * @internal\n */\nexport const CORE_SDK_VERSION = getEnv('PKG_VERSION') || `${version}-development`\n"],"mappings":";;;;;ACOA,MAAa,mBAAmB,OAAO,aAAa,KAAK"}
|
package/package.json
CHANGED
|
@@ -9,6 +9,15 @@ export const DOCUMENT_STATE_CLEAR_DELAY = 1000
|
|
|
9
9
|
export const INITIAL_OUTGOING_THROTTLE_TIME = 1000
|
|
10
10
|
export const API_VERSION = 'v2025-05-06'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* How long a submitted transaction's revision is kept around waiting to be
|
|
14
|
+
* verified by its listener echo. After this, the transaction is no longer
|
|
15
|
+
* treated as in flight and a document held onto only for that verification can
|
|
16
|
+
* be evicted. Sized to match the listener's own chaining deadline, after which
|
|
17
|
+
* an echo that has not arrived is not going to.
|
|
18
|
+
*/
|
|
19
|
+
export const UNVERIFIED_REVISION_RETENTION_TIME = 30_000
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* Base delay (ms) before retrying a document listener after an `OutOfSyncError`.
|
|
14
23
|
* Backoff doubles on each successive retry, capped at {@link OUT_OF_SYNC_RETRY_MAX_DELAY}.
|
|
@@ -357,6 +357,75 @@ it('emits remote-patches events distinguishing own and foreign transactions', as
|
|
|
357
357
|
state2Unsubscribe()
|
|
358
358
|
})
|
|
359
359
|
|
|
360
|
+
it('labels an echo that arrives after the last subscriber left as its own', async () => {
|
|
361
|
+
const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
|
|
362
|
+
const draftId = getDraftId(DocumentId(doc.documentId))
|
|
363
|
+
const state = getDocumentState(instance, doc)
|
|
364
|
+
const unsubscribe = state.subscribe()
|
|
365
|
+
await vi.waitUntil(() => state.getCurrent() !== undefined)
|
|
366
|
+
const previousRev = state.getCurrent()?._rev
|
|
367
|
+
|
|
368
|
+
// ack without emitting the listener echo, so the echo can be delivered later
|
|
369
|
+
// and out of band, which is the ordering the real API produces
|
|
370
|
+
const clientActionMockImplementation = vi.mocked(client.action).getMockImplementation()!
|
|
371
|
+
vi.mocked(client.action).mockImplementation(
|
|
372
|
+
async (_input, {transactionId = crypto.randomUUID()} = {}) => ({transactionId}),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
const patchEvents: DocumentRemotePatchesEvent[] = []
|
|
376
|
+
const unsubscribeEvents = subscribeDocumentEvents(instance, {
|
|
377
|
+
resource,
|
|
378
|
+
eventHandler: (e) => {
|
|
379
|
+
if (e.type === 'remote-patches') patchEvents.push(e)
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
const edited = await applyDocumentActions(instance, {
|
|
384
|
+
actions: [editDocument(doc, {set: {title: 'edited while leaving'}})],
|
|
385
|
+
resource,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// the last subscriber leaves before the ack comes back, which used to delete
|
|
389
|
+
// the document state (and with it the memory of our own transaction) as soon
|
|
390
|
+
// as the ack released the transaction's subscription
|
|
391
|
+
unsubscribe()
|
|
392
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
393
|
+
await edited.submitted()
|
|
394
|
+
|
|
395
|
+
const {events: listenerEvents} = (
|
|
396
|
+
createSharedListener as () => {
|
|
397
|
+
dispose: () => void
|
|
398
|
+
events: Subject<ListenEvent<SanityDocument>>
|
|
399
|
+
}
|
|
400
|
+
)()
|
|
401
|
+
listenerEvents.next({
|
|
402
|
+
type: 'mutation',
|
|
403
|
+
documentId: draftId,
|
|
404
|
+
eventId: `${edited.transactionId}#${draftId}`,
|
|
405
|
+
identity: 'example-user',
|
|
406
|
+
mutations: [{patch: {id: draftId, set: {title: 'edited while leaving'}}}],
|
|
407
|
+
timestamp: new Date().toISOString(),
|
|
408
|
+
transactionId: edited.transactionId,
|
|
409
|
+
transactionCurrentEvent: 1,
|
|
410
|
+
transactionTotalEvents: 1,
|
|
411
|
+
transition: 'update',
|
|
412
|
+
visibility: 'query',
|
|
413
|
+
previousRev,
|
|
414
|
+
resultRev: edited.transactionId,
|
|
415
|
+
} satisfies MutationEvent)
|
|
416
|
+
await vi.waitUntil(() => patchEvents.length > 0)
|
|
417
|
+
|
|
418
|
+
expect(patchEvents).toHaveLength(1)
|
|
419
|
+
expect(patchEvents[0]).toMatchObject({
|
|
420
|
+
documentId: draftId,
|
|
421
|
+
transactionId: edited.transactionId,
|
|
422
|
+
origin: 'local',
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
unsubscribeEvents()
|
|
426
|
+
vi.mocked(client.action).mockImplementation(clientActionMockImplementation)
|
|
427
|
+
})
|
|
428
|
+
|
|
360
429
|
it('handles concurrent edits and resolves conflicts', async () => {
|
|
361
430
|
const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
|
|
362
431
|
const state1 = getDocumentState(instance1, doc)
|
|
@@ -4,6 +4,7 @@ import {parse} from 'groq-js'
|
|
|
4
4
|
import {Subject} from 'rxjs'
|
|
5
5
|
import {describe, expect, it} from 'vitest'
|
|
6
6
|
|
|
7
|
+
import {UNVERIFIED_REVISION_RETENTION_TIME} from './documentConstants'
|
|
7
8
|
import {type DocumentEvent} from './events'
|
|
8
9
|
import {type RemoteDocument} from './listen'
|
|
9
10
|
import {type DocumentSet} from './processMutations'
|
|
@@ -556,6 +557,118 @@ describe('cleanupOutgoingTransaction', () => {
|
|
|
556
557
|
})
|
|
557
558
|
})
|
|
558
559
|
|
|
560
|
+
describe('document state retention between ack and echo', () => {
|
|
561
|
+
const docId = 'doc1'
|
|
562
|
+
const justNow = new Date().toISOString()
|
|
563
|
+
const longAgo = new Date(Date.now() - UNVERIFIED_REVISION_RETENTION_TIME - 1000).toISOString()
|
|
564
|
+
|
|
565
|
+
const outgoing: NonNullable<SyncTransactionState['outgoing']> = {
|
|
566
|
+
transactionId: 'txnOut',
|
|
567
|
+
actions: [
|
|
568
|
+
{
|
|
569
|
+
type: 'document.edit',
|
|
570
|
+
documentId: docId,
|
|
571
|
+
documentType: 'book',
|
|
572
|
+
liveEdit: true,
|
|
573
|
+
patches: [{set: {foo: 'changed'}}],
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
disableBatching: false,
|
|
577
|
+
batchedTransactionIds: ['txnOut'],
|
|
578
|
+
outgoingActions: [],
|
|
579
|
+
outgoingMutations: [],
|
|
580
|
+
base: {},
|
|
581
|
+
working: {},
|
|
582
|
+
previous: {},
|
|
583
|
+
previousRevs: {},
|
|
584
|
+
timestamp: justNow,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const unverifiedRevision = (timestamp: string) => ({
|
|
588
|
+
txnOut: {transactionId: 'txnOut', documentId: docId, previousRev: 'txn0', timestamp},
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
const stateAfterAck = (timestamp: string): SyncTransactionState => ({
|
|
592
|
+
queued: [],
|
|
593
|
+
applied: [],
|
|
594
|
+
outgoing,
|
|
595
|
+
grants,
|
|
596
|
+
documentStates: {
|
|
597
|
+
[docId]: {
|
|
598
|
+
id: docId,
|
|
599
|
+
// the UI unsubscribed before the ack arrived, so the transaction's
|
|
600
|
+
// own subscription id is the only one left
|
|
601
|
+
subscriptions: ['txnOut'],
|
|
602
|
+
local: {...exampleDoc, _id: docId, foo: 'changed'},
|
|
603
|
+
remote: {...exampleDoc, _id: docId, foo: 'old'},
|
|
604
|
+
unverifiedRevisions: unverifiedRevision(timestamp),
|
|
605
|
+
recentOwnTransactionIds: ['txnOut'],
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('keeps echo-recognition memory alive through cleanup so a late echo still labels "local"', () => {
|
|
611
|
+
// the HTTP ack arrives and cleans up the outgoing transaction's
|
|
612
|
+
// subscription. previously this deleted the whole DocumentState
|
|
613
|
+
// immediately, before the listener echo had a chance to arrive
|
|
614
|
+
const afterCleanup = cleanupOutgoingTransaction(stateAfterAck(justNow))
|
|
615
|
+
expect(afterCleanup.outgoing).toBeUndefined()
|
|
616
|
+
|
|
617
|
+
const docStateAfterCleanup = afterCleanup.documentStates[docId]
|
|
618
|
+
expect(docStateAfterCleanup).toBeDefined()
|
|
619
|
+
expect(docStateAfterCleanup?.subscriptions).toEqual([])
|
|
620
|
+
expect(docStateAfterCleanup?.unverifiedRevisions?.['txnOut']).toBeDefined()
|
|
621
|
+
|
|
622
|
+
// now the echo finally arrives over the shared listener
|
|
623
|
+
const events = new Subject<DocumentEvent>()
|
|
624
|
+
const received: DocumentEvent[] = []
|
|
625
|
+
events.subscribe((e) => received.push(e))
|
|
626
|
+
|
|
627
|
+
const echo: RemoteDocument = {
|
|
628
|
+
type: 'mutation',
|
|
629
|
+
documentId: docId,
|
|
630
|
+
document: {...exampleDoc, _id: docId, foo: 'changed'},
|
|
631
|
+
revision: 'txnOut',
|
|
632
|
+
previousRev: 'txn0',
|
|
633
|
+
timestamp: new Date().toISOString(),
|
|
634
|
+
mutations: [{patch: {id: docId, set: {foo: 'changed'}}}],
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const afterEcho = applyRemoteDocument(afterCleanup, echo, events)
|
|
638
|
+
|
|
639
|
+
const remotePatchesEvents = received.filter((e) => e.type === 'remote-patches')
|
|
640
|
+
expect(remotePatchesEvents).toHaveLength(1)
|
|
641
|
+
expect(remotePatchesEvents[0]).toMatchObject({origin: 'local', transactionId: 'txnOut'})
|
|
642
|
+
|
|
643
|
+
// the echo verified the revision and nothing was subscribed any more, so
|
|
644
|
+
// the now fully-idle document state can finally be evicted
|
|
645
|
+
expect(afterEcho.documentStates[docId]).toBeUndefined()
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('stops waiting for an echo that is older than the retention time', () => {
|
|
649
|
+
// an accepted transaction whose operations all no-op server-side never
|
|
650
|
+
// produces an echo, so the wait has to expire or the document (and its
|
|
651
|
+
// listener) would be pinned for the lifetime of the store
|
|
652
|
+
const afterCleanup = cleanupOutgoingTransaction(stateAfterAck(longAgo))
|
|
653
|
+
expect(afterCleanup.documentStates[docId]).toBeUndefined()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('does not extend the wait on `recentOwnTransactionIds` alone', () => {
|
|
657
|
+
const state = stateAfterAck(justNow)
|
|
658
|
+
const documentState = state.documentStates[docId]!
|
|
659
|
+
const withoutUnverifiedRevisions: SyncTransactionState = {
|
|
660
|
+
...state,
|
|
661
|
+
documentStates: {
|
|
662
|
+
[docId]: {...documentState, unverifiedRevisions: {}, recentOwnTransactionIds: ['txnOut']},
|
|
663
|
+
},
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
expect(
|
|
667
|
+
cleanupOutgoingTransaction(withoutUnverifiedRevisions).documentStates[docId],
|
|
668
|
+
).toBeUndefined()
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
|
|
559
672
|
describe('revertOutgoingTransaction', () => {
|
|
560
673
|
it('reverts the outgoing transaction and updates documentStates by removing unverified revisions', () => {
|
|
561
674
|
const draftId = getDraftId(DocumentId('doc1'))
|
|
@@ -686,6 +799,117 @@ describe('revertOutgoingTransaction', () => {
|
|
|
686
799
|
// transaction id is gone from its subscriptions
|
|
687
800
|
expect(newState.documentStates[pubId]?.subscriptions).toEqual(['sub-ui'])
|
|
688
801
|
})
|
|
802
|
+
|
|
803
|
+
it('evicts a document left with no subscribers once its unverified revision is discarded', () => {
|
|
804
|
+
const docId = 'doc1'
|
|
805
|
+
const state: SyncTransactionState = {
|
|
806
|
+
queued: [],
|
|
807
|
+
applied: [],
|
|
808
|
+
outgoing: {
|
|
809
|
+
transactionId: 'txnOut',
|
|
810
|
+
actions: [
|
|
811
|
+
{
|
|
812
|
+
type: 'document.edit',
|
|
813
|
+
documentId: docId,
|
|
814
|
+
documentType: 'book',
|
|
815
|
+
liveEdit: true,
|
|
816
|
+
patches: [{set: {foo: 'changed'}}],
|
|
817
|
+
},
|
|
818
|
+
],
|
|
819
|
+
disableBatching: false,
|
|
820
|
+
batchedTransactionIds: ['txnOut'],
|
|
821
|
+
outgoingActions: [],
|
|
822
|
+
outgoingMutations: [],
|
|
823
|
+
base: {},
|
|
824
|
+
working: {},
|
|
825
|
+
previous: {},
|
|
826
|
+
previousRevs: {},
|
|
827
|
+
timestamp: new Date().toISOString(),
|
|
828
|
+
},
|
|
829
|
+
grants,
|
|
830
|
+
documentStates: {
|
|
831
|
+
// the reverted transaction was this document's last subscriber, so the
|
|
832
|
+
// revision discarded below is the only thing keeping it around
|
|
833
|
+
[docId]: {
|
|
834
|
+
id: docId,
|
|
835
|
+
subscriptions: [],
|
|
836
|
+
local: {...exampleDoc, _id: docId, foo: 'changed'},
|
|
837
|
+
remote: {...exampleDoc, _id: docId, foo: 'old'},
|
|
838
|
+
unverifiedRevisions: {
|
|
839
|
+
txnOut: {
|
|
840
|
+
transactionId: 'txnOut',
|
|
841
|
+
documentId: docId,
|
|
842
|
+
previousRev: 'txn0',
|
|
843
|
+
timestamp: new Date().toISOString(),
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
expect(revertOutgoingTransaction(state).documentStates[docId]).toBeUndefined()
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('leaves `local` intact for a document still waiting on its own acked transaction', () => {
|
|
854
|
+
const revertedId = 'doc-reverted'
|
|
855
|
+
const retainedId = 'doc-retained'
|
|
856
|
+
const state: SyncTransactionState = {
|
|
857
|
+
queued: [],
|
|
858
|
+
applied: [],
|
|
859
|
+
outgoing: {
|
|
860
|
+
transactionId: 'txnOut',
|
|
861
|
+
actions: [
|
|
862
|
+
{
|
|
863
|
+
type: 'document.edit',
|
|
864
|
+
documentId: revertedId,
|
|
865
|
+
documentType: 'book',
|
|
866
|
+
liveEdit: true,
|
|
867
|
+
patches: [{set: {foo: 'changed'}}],
|
|
868
|
+
},
|
|
869
|
+
],
|
|
870
|
+
disableBatching: false,
|
|
871
|
+
batchedTransactionIds: ['txnOut'],
|
|
872
|
+
outgoingActions: [],
|
|
873
|
+
outgoingMutations: [],
|
|
874
|
+
base: {},
|
|
875
|
+
working: {},
|
|
876
|
+
previous: {},
|
|
877
|
+
previousRevs: {},
|
|
878
|
+
timestamp: new Date().toISOString(),
|
|
879
|
+
},
|
|
880
|
+
grants,
|
|
881
|
+
documentStates: {
|
|
882
|
+
[revertedId]: {
|
|
883
|
+
id: revertedId,
|
|
884
|
+
subscriptions: ['txnOut'],
|
|
885
|
+
local: {...exampleDoc, _id: revertedId, foo: 'changed'},
|
|
886
|
+
remote: {...exampleDoc, _id: revertedId, foo: 'old'},
|
|
887
|
+
},
|
|
888
|
+
// unrelated to the reverted batch: acked, unsubscribed, and still
|
|
889
|
+
// waiting on the echo that will settle its optimistic value
|
|
890
|
+
[retainedId]: {
|
|
891
|
+
id: retainedId,
|
|
892
|
+
subscriptions: [],
|
|
893
|
+
local: {...exampleDoc, _id: retainedId, foo: 'acked'},
|
|
894
|
+
remote: {...exampleDoc, _id: retainedId, foo: 'old'},
|
|
895
|
+
unverifiedRevisions: {
|
|
896
|
+
txnAcked: {
|
|
897
|
+
transactionId: 'txnAcked',
|
|
898
|
+
documentId: retainedId,
|
|
899
|
+
previousRev: 'txn0',
|
|
900
|
+
timestamp: new Date().toISOString(),
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const newState = revertOutgoingTransaction(state)
|
|
908
|
+
// the reverted document's only subscriber was the batch itself, so
|
|
909
|
+
// unregistering it evicts the state
|
|
910
|
+
expect(newState.documentStates[revertedId]).toBeUndefined()
|
|
911
|
+
expect(newState.documentStates[retainedId]?.local).toMatchObject({foo: 'acked'})
|
|
912
|
+
})
|
|
689
913
|
})
|
|
690
914
|
|
|
691
915
|
describe('applyRemoteDocument', () => {
|
package/src/document/reducers.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {insecureRandomId} from '../utils/ids'
|
|
|
8
8
|
import {isDeepEqual, omitProperty} from '../utils/object'
|
|
9
9
|
import {setCleanupTimeout} from '../utils/setCleanupTimeout'
|
|
10
10
|
import {type Action} from './actions'
|
|
11
|
-
import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
|
|
11
|
+
import {DOCUMENT_STATE_CLEAR_DELAY, UNVERIFIED_REVISION_RETENTION_TIME} from './documentConstants'
|
|
12
12
|
import {type DocumentState, type DocumentStoreState} from './documentStore'
|
|
13
13
|
import {type RemoteDocument} from './listen'
|
|
14
14
|
import {ActionError, processActions} from './processActions/processActions'
|
|
@@ -401,7 +401,13 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
|
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
-
|
|
404
|
+
// every ack is also when a previously retained document's echo may have
|
|
405
|
+
// stopped being expected, so re-make that eviction decision here
|
|
406
|
+
return {
|
|
407
|
+
...next,
|
|
408
|
+
outgoing: undefined,
|
|
409
|
+
documentStates: evictOrphanedDocumentStates(next.documentStates),
|
|
410
|
+
}
|
|
405
411
|
}
|
|
406
412
|
|
|
407
413
|
export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTransactionState {
|
|
@@ -446,20 +452,38 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
|
|
|
446
452
|
...base,
|
|
447
453
|
applied: nextApplied,
|
|
448
454
|
outgoing: undefined,
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
455
|
+
// discarding the outgoing transaction drops its echo-recognition memory,
|
|
456
|
+
// which can leave a document that was only being kept alive for that echo
|
|
457
|
+
// with nothing left to wait for, so sweep afterwards
|
|
458
|
+
documentStates: evictOrphanedDocumentStates(
|
|
459
|
+
Object.fromEntries(
|
|
460
|
+
Object.entries(base.documentStates)
|
|
461
|
+
.filter((e): e is [string, DocumentState] => !!e[1])
|
|
462
|
+
.map(([documentId, documentState]) => {
|
|
463
|
+
const {unverifiedRevisions = {}} = documentState
|
|
464
|
+
const next: DocumentState = {
|
|
465
|
+
...documentState,
|
|
466
|
+
unverifiedRevisions:
|
|
467
|
+
prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
|
|
468
|
+
? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
|
|
469
|
+
: unverifiedRevisions,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// a document with no subscribers that is still waiting on the echo
|
|
473
|
+
// of a different, already acked transaction of its own is not
|
|
474
|
+
// represented in `working` (only `applied` transactions are
|
|
475
|
+
// re-applied above), so rebuilding its `local` from `remote` would
|
|
476
|
+
// drop that transaction's effect. leave it for the echo to resolve
|
|
477
|
+
if (!next.subscriptions.length && isAwaitingOwnEcho(next)) {
|
|
478
|
+
return [documentId, next] as const
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return [
|
|
482
|
+
documentId,
|
|
483
|
+
{...next, local: documentId in working ? working[documentId] : next.local},
|
|
484
|
+
] as const
|
|
485
|
+
}),
|
|
486
|
+
),
|
|
463
487
|
),
|
|
464
488
|
}
|
|
465
489
|
}
|
|
@@ -579,7 +603,7 @@ export function applyRemoteDocument(
|
|
|
579
603
|
|
|
580
604
|
return {
|
|
581
605
|
...prev,
|
|
582
|
-
documentStates: {
|
|
606
|
+
documentStates: evictOrphanedDocumentStates({
|
|
583
607
|
...prev.documentStates,
|
|
584
608
|
[documentId]: {
|
|
585
609
|
...prevDocState,
|
|
@@ -588,7 +612,7 @@ export function applyRemoteDocument(
|
|
|
588
612
|
local,
|
|
589
613
|
unverifiedRevisions,
|
|
590
614
|
},
|
|
591
|
-
},
|
|
615
|
+
}),
|
|
592
616
|
}
|
|
593
617
|
}
|
|
594
618
|
|
|
@@ -645,7 +669,7 @@ export function applyRemoteDocument(
|
|
|
645
669
|
return {
|
|
646
670
|
...prev,
|
|
647
671
|
applied: nextApplied,
|
|
648
|
-
documentStates: {
|
|
672
|
+
documentStates: evictOrphanedDocumentStates({
|
|
649
673
|
...prev.documentStates,
|
|
650
674
|
[documentId]: {
|
|
651
675
|
...prevDocState,
|
|
@@ -654,7 +678,7 @@ export function applyRemoteDocument(
|
|
|
654
678
|
local,
|
|
655
679
|
unverifiedRevisions,
|
|
656
680
|
},
|
|
657
|
-
},
|
|
681
|
+
}),
|
|
658
682
|
}
|
|
659
683
|
}
|
|
660
684
|
|
|
@@ -685,22 +709,81 @@ export function removeSubscriptionIdFromDocument(
|
|
|
685
709
|
subscriptionId: string,
|
|
686
710
|
): SyncTransactionState {
|
|
687
711
|
const prevDocState = prev.documentStates?.[documentId]
|
|
688
|
-
const prevSubscriptions = prevDocState?.subscriptions ?? []
|
|
689
|
-
const subscriptions = prevSubscriptions.filter((id) => id !== subscriptionId)
|
|
690
|
-
|
|
691
712
|
if (!prevDocState) return prev
|
|
692
|
-
|
|
713
|
+
|
|
714
|
+
const subscriptions = prevDocState.subscriptions.filter((id) => id !== subscriptionId)
|
|
715
|
+
|
|
716
|
+
// a document with no subscribers left is normally evicted below, but if a
|
|
717
|
+
// transaction was just submitted for it, its echo-recognition memory
|
|
718
|
+
// (`unverifiedRevisions`) is still waiting on the listener echo.
|
|
719
|
+
// `cleanupOutgoingTransaction` removes the outgoing transaction's own
|
|
720
|
+
// subscription right at HTTP ack, before the echo arrives, so deleting the
|
|
721
|
+
// state here would lose that memory and make the echo mislabel as
|
|
722
|
+
// `origin: 'remote'` (or, worse, get skipped outright). keep the state
|
|
723
|
+
// alive, unsubscribed, until the echo verifies or the wait expires
|
|
724
|
+
if (!subscriptions.length && !isAwaitingOwnEcho(prevDocState)) {
|
|
693
725
|
return {...prev, documentStates: omitProperty(prev.documentStates, documentId)}
|
|
694
726
|
}
|
|
727
|
+
|
|
695
728
|
return {
|
|
696
729
|
...prev,
|
|
697
730
|
documentStates: {
|
|
698
731
|
...prev.documentStates,
|
|
699
|
-
[documentId]: {...prevDocState, subscriptions
|
|
732
|
+
[documentId]: {...prevDocState, subscriptions},
|
|
700
733
|
},
|
|
701
734
|
}
|
|
702
735
|
}
|
|
703
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Whether this document still expects the listener echo of one of its own
|
|
739
|
+
* submitted transactions. That expectation is the only reason a document state
|
|
740
|
+
* with no subscribers is kept around.
|
|
741
|
+
*
|
|
742
|
+
* Revisions stop counting after `UNVERIFIED_REVISION_RETENTION_TIME` because an
|
|
743
|
+
* echo is not guaranteed to arrive at all: Content Lake records no transaction
|
|
744
|
+
* when every operation resolves to a no-op (e.g. a keyed path that no longer
|
|
745
|
+
* exists), while the client bumps `_rev` regardless and so tracks a revision
|
|
746
|
+
* for it. Without the expiry, such a document (and the listener keyed off its
|
|
747
|
+
* state) would be pinned for the lifetime of the store.
|
|
748
|
+
*
|
|
749
|
+
* `recentOwnTransactionIds` deliberately does not extend the wait: it is capped
|
|
750
|
+
* rather than drained, so honoring it would keep every edited document alive.
|
|
751
|
+
*/
|
|
752
|
+
function isAwaitingOwnEcho(documentState: DocumentState): boolean {
|
|
753
|
+
const expiresAfter = Date.now() - UNVERIFIED_REVISION_RETENTION_TIME
|
|
754
|
+
return Object.values(documentState.unverifiedRevisions ?? {}).some(
|
|
755
|
+
(revision) => !!revision && new Date(revision.timestamp).getTime() > expiresAfter,
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Removes every document state that has no subscribers left and no own
|
|
761
|
+
* transaction still awaiting its echo.
|
|
762
|
+
*
|
|
763
|
+
* This is a sweep rather than a decision made where the last subscriber leaves
|
|
764
|
+
* because `removeSubscriptionIdFromDocument` defers that eviction while an echo
|
|
765
|
+
* is in flight, and both halves of the condition can change without a
|
|
766
|
+
* subscriber going anywhere: the echo can verify, be dropped by a sync, be
|
|
767
|
+
* discarded by a revert, or simply never arrive. Every reducer that can settle
|
|
768
|
+
* an in-flight transaction sweeps, so the deferred decision is always re-made.
|
|
769
|
+
*
|
|
770
|
+
* Note this can evict the very document whose remote event is being processed,
|
|
771
|
+
* which synchronously unsubscribes that document's listener from inside its own
|
|
772
|
+
* emission (see `subscribeToSubscriptionsAndListenToDocuments`).
|
|
773
|
+
*/
|
|
774
|
+
function evictOrphanedDocumentStates(
|
|
775
|
+
documentStates: SyncTransactionState['documentStates'],
|
|
776
|
+
): SyncTransactionState['documentStates'] {
|
|
777
|
+
let next = documentStates
|
|
778
|
+
for (const [documentId, documentState] of Object.entries(documentStates)) {
|
|
779
|
+
if (!documentState) continue
|
|
780
|
+
if (documentState.subscriptions.length) continue
|
|
781
|
+
if (isAwaitingOwnEcho(documentState)) continue
|
|
782
|
+
next = omitProperty(next, documentId)
|
|
783
|
+
}
|
|
784
|
+
return next
|
|
785
|
+
}
|
|
786
|
+
|
|
704
787
|
export function manageSubscriberIds(
|
|
705
788
|
{state}: StoreContext<SyncTransactionState>,
|
|
706
789
|
handles: DocumentHandleLike[],
|