@sanity/mutate
Version:
Experimental toolkit for working with Sanity mutations in JavaScript & TypeScript
454 lines (453 loc) • 17.6 kB
JavaScript
import { applyMutations, rebase, toTransactions, squashDMPStrings, squashMutationGroups } from "./_chunks-es/toTransactions.js";
import { applyPatch } from "mendoza";
import { share, filter, merge, shareReplay, defer, observeOn, asapScheduler } from "rxjs";
import { setup, fromPromise, fromEventObservable, sendParent, assertEvent, enqueueActions, assign, stopChild, spawnChild, raise } from "xstate";
import { encodeTransaction } from "./_chunks-es/encode.js";
import "./_chunks-es/utils.js";
function commit(results, documentMap) {
results.forEach((result) => {
(result.status === "created" || result.status === "updated") && documentMap.set(result.id, result.after), result.status === "deleted" && documentMap.delete(result.id);
});
}
function createSharedListener(client) {
const allEvents$ = client.listen(
'*[!(_id in path("_.**"))]',
{},
{
events: ["welcome", "mutation", "reconnect"],
includeResult: !1,
includePreviousRevision: !1,
visibility: "transaction",
effectFormat: "mendoza",
includeMutations: !1
}
).pipe(share({ resetOnRefCountZero: !0 })), reconnect = allEvents$.pipe(
filter((event) => event.type === "reconnect")
), welcome = allEvents$.pipe(
filter((event) => event.type === "welcome")
), mutations = allEvents$.pipe(
filter((event) => event.type === "mutation")
), replayWelcome = merge(welcome, reconnect).pipe(
shareReplay({ bufferSize: 1, refCount: !0 })
).pipe(
filter((latestConnectionEvent) => latestConnectionEvent.type === "welcome")
);
return merge(replayWelcome, mutations, reconnect);
}
const documentMutatorMachine = setup({
types: {},
actions: {
"assign error to context": assign({ error: ({ event }) => event }),
"clear error from context": assign({ error: void 0 }),
"connect to server-sent events": raise({ type: "connect" }),
"listen to server-sent events": spawnChild("server-sent events", {
id: "listener",
input: ({ context }) => ({
listener: context.sharedListener || createSharedListener(context.client),
id: context.id
})
}),
"stop listening to server-sent events": stopChild("listener"),
"buffer remote mutation events": assign({
mutationEvents: ({ event, context }) => (assertEvent(event, "mutation"), [...context.mutationEvents, event])
}),
"restore stashed changes": assign({
stagedChanges: ({ event, context }) => (assertEvent(event, "xstate.done.actor.submitTransactions"), context.stashedChanges),
stashedChanges: []
}),
"rebase fetched remote snapshot": enqueueActions(({ enqueue }) => {
enqueue.assign(({ event, context }) => {
assertEvent(event, "xstate.done.actor.getDocument");
const previousRemote = context.remote;
let nextRemote = event.output, seenCurrentRev = !1;
for (const patch of context.mutationEvents)
!patch.effects?.apply || !patch.previousRev && patch.transition !== "appear" || (!seenCurrentRev && patch.previousRev === nextRemote?._rev && (seenCurrentRev = !0), seenCurrentRev && (nextRemote = applyMendozaPatch(
nextRemote,
patch.effects.apply,
patch.resultRev
)));
context.cache && // If the shared cache don't have the document already we can just set it
(!context.cache.has(context.id) || // But when it's in the cache, make sure it's necessary to update it
context.cache.get(context.id)._rev !== nextRemote?._rev) && context.cache.set(context.id, nextRemote);
const [stagedChanges, local] = rebase(
context.id,
// It's annoying to convert between null and undefined, reach consensus
previousRemote === null ? void 0 : previousRemote,
nextRemote === null ? void 0 : nextRemote,
context.stagedChanges
);
return {
remote: nextRemote,
local,
stagedChanges,
// Since the snapshot handler applies all the patches they are no longer needed, allow GC
mutationEvents: []
};
}), enqueue.sendParent(
({ context }) => ({
type: "rebased.remote",
id: context.id,
document: context.remote
})
);
}),
"apply mendoza patch": assign(({ event, context }) => {
assertEvent(event, "mutation");
const previousRemote = context.remote;
if (event.transactionId === previousRemote?._rev)
return {};
const nextRemote = applyMendozaPatch(
previousRemote,
event.effects.apply,
event.resultRev
);
context.cache && // If the shared cache don't have the document already we can just set it
(!context.cache.has(context.id) || // But when it's in the cache, make sure it's necessary to update it
context.cache.get(context.id)._rev !== nextRemote?._rev) && context.cache.set(context.id, nextRemote);
const [stagedChanges, local] = rebase(
context.id,
// It's annoying to convert between null and undefined, reach consensus
previousRemote === null ? void 0 : previousRemote,
nextRemote === null ? void 0 : nextRemote,
context.stagedChanges
);
return {
remote: nextRemote,
local,
stagedChanges
};
}),
"increment fetch attempts": assign({
fetchRemoteSnapshotAttempts: ({ context }) => context.fetchRemoteSnapshotAttempts + 1
}),
"reset fetch attempts": assign({
fetchRemoteSnapshotAttempts: 0
}),
"increment submit attempts": assign({
submitTransactionsAttempts: ({ context }) => context.submitTransactionsAttempts + 1
}),
"reset submit attempts": assign({
submitTransactionsAttempts: 0
}),
"stage mutation": assign({
stagedChanges: ({ event, context }) => (assertEvent(event, "mutate"), [
...context.stagedChanges,
{ transaction: !1, mutations: event.mutations }
])
}),
"stash mutation": assign({
stashedChanges: ({ event, context }) => (assertEvent(event, "mutate"), [
...context.stashedChanges,
{ transaction: !1, mutations: event.mutations }
])
}),
"rebase local snapshot": enqueueActions(({ enqueue }) => {
enqueue.assign({
local: ({ event, context }) => {
assertEvent(event, "mutate");
const localDataset = /* @__PURE__ */ new Map();
context.local && localDataset.set(context.id, context.local);
const results = applyMutations(event.mutations, localDataset);
return commit(results, localDataset), localDataset.get(context.id);
}
}), enqueue.sendParent(
({ context }) => ({
type: "rebased.local",
id: context.id,
document: context.local
})
);
}),
"send pristine event to parent": sendParent(
({ context }) => ({
type: "pristine",
id: context.id
})
),
"send sync event to parent": sendParent(
({ context }) => ({
type: "sync",
id: context.id,
document: context.remote
})
),
"send mutation event to parent": sendParent(({ context, event }) => (assertEvent(event, "mutation"), {
type: "mutation",
id: context.id,
previousRev: event.previousRev,
resultRev: event.resultRev,
effects: event.effects
}))
},
actors: {
"server-sent events": fromEventObservable(
({
input
}) => {
const { listener, id } = input;
return defer(() => listener).pipe(
filter(
(event) => event.type === "welcome" || event.type === "reconnect" || event.type === "mutation" && event.documentId === id
),
// This is necessary to avoid sync emitted events from `shareReplay` from happening before the actor is ready to receive them
observeOn(asapScheduler)
);
}
),
"fetch remote snapshot": fromPromise(
async ({
input,
signal
}) => {
const { client, id } = input;
return await client.getDocument(id, {
signal
}).catch((e) => {
if (!(e instanceof Error && e.name === "AbortError"))
throw e;
});
}
),
"submit mutations as transactions": fromPromise(
async ({
input,
signal
}) => {
const { client, transactions } = input;
for (const transaction of transactions) {
if (signal.aborted) return;
await client.dataRequest("mutate", encodeTransaction(transaction), {
visibility: "async",
returnDocuments: !1,
signal
}).catch((e) => {
if (!(e instanceof Error && e.name === "AbortError"))
throw e;
});
}
}
)
},
delays: {
// Exponential backoff delay function
fetchRemoteSnapshotTimeout: ({ context }) => Math.pow(2, context.fetchRemoteSnapshotAttempts) * 1e3,
submitTransactionsTimeout: ({ context }) => Math.pow(2, context.submitTransactionsAttempts) * 1e3
}
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QQPYGMCuBbMA7ALgLRYb4CG+KATgMQnn5gDaADALqKgAOKsAlvj4pcnEAA9EADhYAWAHQA2GSwUBmAKwBOaQEZJkhQBoQAT0Q6ATAF8rx1JhwFipCtTkQ+sNMNxg0jCBpvXF9-Vg4kEB5+QWFRCQQLFhY5PQB2dRU1SQsdGSVjMwRlHTkWNM00nR1VKryKmzt0bDwielcqOWDQwVwoGgB3MAAbbxxw0WiBIRFIhPUKuQ1NVU0tTU0WFdVCxAt1dTlNhX2WdRltGUk061sQexandspO7r9e-qo-H3eJyKnYrNQPNJJollpVutNttdghVCo5MoTgoMpo8jIkqpGvdmo42i4Xl0fv4+H0aGAqFRqH9uLxpnE5ogFrCLFd5CtNBYFJkdOpuaosXcHnjnAw3G9-AAxMh8YYYL5BYn4GlROmA+KIFEs1R6ORpZYWHIXGR6bHC1qijpyL4Sj6DEZjZjsSZqmYaxK1ORcvlc-ZqSoyNKwnRpGTyK4WDK5BQ6BQ5BRm3EW55uG1K0n9ClUqgqgFuxketJe7nIv2rUNB0x7LmSL2qGMsSySevcmSJhzJgnipWQOgEma510M4HmcqHZYyWqaOMqTQyWGh0osVSGiwC5uGyftx74sWvHuBNMhX7O-5DoHiPae72lvnlwPBydFlRVS7qPIl7cilP74-+SByMMKBkB4ZKoL4cikgAbigADWYByDA+AACJJgQg4xPmI7FHkZQrKGsjqKcCiaMG9YpJOxySBiCiyPoX6dnuRJ-gEgHAaBmaUm4XDDBQABm1BYIhYAoWhyqnrSmHDpeCAKCicjUbU6g6nkFichYsJrLWVxaPsk7KdICZCmJlqEraAFASBvbPAOEmqlJF4JJURbVDcy4Yvk1GVkUsabApMjKZGqjNg2kgMU8Xa-j0FnsQBXBUJ4vRgH2DBOhEkn0o5TLKV6y6WDG6lhkYVYIDoKzyBkeR8pUDZqOFu5WuZEBsVZzUeFQ+AmDQsAYAARlgAgYZl7o6I2tYLJINSSO+3JaMGlQpGkhFhryo2jW2xkdhFTFNS1EAAT1-UCHa4EIdBcEIYdA34AAKlQZC4LAZAksIsBDeqBbrdpym8iuByxlcLK5BYqQLBUZyTcFvL1aZ3YsTFrVyFdx0ZuSXGdDx-GCUjfXXXdD1PS9j3vVhMnrWCFRxtNaRLdcfIsiwuS5fkgZaOUlhpDDP7MdFzWWftzXI-gdrPGlLoOSNyiHFstRySisjcgzahyFoUYBbRsac5tO6w1F7wIwLONHfg0qyvKyVfPgVAmCT0kJGt41pJD02xgcpElUkcZ6rI+q5JcckbU0W0NWZB57QduMCKbcoKmIsCpXIZB8YwVAABRC-jj3PYCsA3XwOAoKQACUNDmttjVh-zEfG9H5u21lpVjSrTtTTNbsstUYJJAFWgA37XORTz+t8+xtcKpb1v1+6KJFopGQqRi6nBlstYcqNK5riusYDztlejzKMfJXHCdJynqd8SJaAABYAEpgFgKCMAAyrgZBcLAV+P3nBfF6XJnc7tfmY8xZnglgWGe-klILzUhYDSJVRpnCONNOcWxWRKBRDYO4uAUD7XgJEMuIdqDi2GgWQgOhgxMyRKyFc8IuQkXUDvK0HgvAHmIR9bCD4SoeSWCoLkoZpxrmXIw0OLEMxsNJgkSc418KhnrHyG4oZYTcPhMifhJx4SCiDjrABSpgHiLtogAUhwW77DRIGXkk55wexKCrJQsg1jBTnPsYRqZviiL6PohuORgyhhBhGKMsZYzxhcXrf8EBPHulUJOPCtRlABWIu7IoORVBIIhMpNYGJyghKHmEvaYjQEkOwhkxQrJtBES0JDOBPlkgKD1JYZSjZORyTXNkwBsVwkFPYTJVYS58JxPKbOYM0gUj7FjGcEMOpciaJxMHXWOTWJV2avFRKpIwARILJRGJBF4mZBIkDE0KtIxLSSDkDYGhWl70Ru1Tq6zsLURBkoLyWRmz8PmlUZmcY1zXDKdMghcy2mIyFh8W5ZN4RFmOFyKaZwiLeT2GVJcy5pBrguCiMK2tvyDwBYbIWejOkSMQBkeQORJq0RNCUDIDNondxuGpPQmRqIXPhiPECuKMpdMkbILZ-SEnLxyjqOJMZsj1jRTYIAA */
id: "document-mutator",
context: ({ input }) => ({
client: input.client.withConfig({ allowReconfigure: !1 }),
sharedListener: input.sharedListener,
id: input.id,
remote: void 0,
local: void 0,
mutationEvents: [],
stagedChanges: [],
stashedChanges: [],
error: void 0,
fetchRemoteSnapshotAttempts: 0,
submitTransactionsAttempts: 0,
cache: input.cache
}),
// Auto start the connection by default
entry: ["connect to server-sent events"],
on: {
mutate: {
actions: ["rebase local snapshot", "stage mutation"]
}
},
initial: "disconnected",
states: {
disconnected: {
on: {
connect: {
target: "connecting",
actions: ["listen to server-sent events"]
}
}
},
connecting: {
on: {
welcome: "connected",
reconnect: "reconnecting",
error: "connectFailure"
},
tags: ["busy"]
},
connectFailure: {
on: {
connect: {
target: "connecting",
actions: ["listen to server-sent events"]
}
},
entry: [
"stop listening to server-sent events",
"assign error to context"
],
exit: ["clear error from context"],
tags: ["error"]
},
reconnecting: {
on: {
welcome: {
target: "connected"
},
error: {
target: "connectFailure"
}
},
tags: ["busy", "error"]
},
connected: {
on: {
mutation: {
actions: ["buffer remote mutation events"]
},
reconnect: "reconnecting"
},
entry: ["clear error from context"],
initial: "loading",
states: {
loading: {
invoke: {
src: "fetch remote snapshot",
id: "getDocument",
input: ({ context }) => ({
client: context.client,
id: context.id
}),
onError: {
target: "loadFailure"
},
onDone: {
target: "loaded",
actions: [
"rebase fetched remote snapshot",
"reset fetch attempts"
]
}
},
tags: ["busy"]
},
loaded: {
entry: ["send sync event to parent"],
on: {
mutation: {
actions: ["apply mendoza patch", "send mutation event to parent"]
}
},
initial: "pristine",
states: {
pristine: {
on: {
mutate: {
actions: ["rebase local snapshot", "stage mutation"],
target: "dirty"
}
},
tags: ["ready"]
},
dirty: {
on: {
submit: "submitting"
},
tags: ["ready"]
},
submitting: {
on: {
mutate: {
actions: ["rebase local snapshot", "stash mutation"]
}
},
invoke: {
src: "submit mutations as transactions",
id: "submitTransactions",
input: ({ context }) => {
const remoteDataset = /* @__PURE__ */ new Map();
return remoteDataset.set(context.id, context.remote), {
client: context.client,
transactions: toTransactions(
// Squashing DMP strings is the last thing we do before submitting
squashDMPStrings(
remoteDataset,
squashMutationGroups(context.stagedChanges)
)
)
};
},
onError: {
target: "submitFailure"
},
onDone: {
target: "pristine",
actions: [
"restore stashed changes",
"reset submit attempts",
"send pristine event to parent"
]
}
},
/**
* 'busy' means we should show a spinner, 'ready' means we can still accept mutations, they'll be applied optimistically right away, and queued for submissions after the current submission settles
*/
tags: ["busy", "ready"]
},
submitFailure: {
exit: ["clear error from context"],
after: {
submitTransactionsTimeout: {
actions: ["increment submit attempts"],
target: "submitting"
}
},
on: {
retry: "submitting"
},
/**
* How can it be both `ready` and `error`? `ready` means it can receive mutations, optimistically apply them, and queue them for submission. `error` means it failed to submit previously applied mutations.
* It's completely fine to keep queueing up more mutations and applying them optimistically, while showing UI that notifies that mutations didn't submit, and show a count down until the next automatic retry.
*/
tags: ["error", "ready"]
}
}
},
loadFailure: {
exit: ["clear error from context"],
after: {
fetchRemoteSnapshotTimeout: {
actions: ["increment fetch attempts"],
target: "loading"
}
},
on: {
retry: "loading"
},
tags: ["error"]
}
}
}
}
});
function applyMendozaPatch(document, patch, nextRevision) {
const next = applyPatch(omitRev(document), patch);
return next ? Object.assign(next, { _rev: nextRevision }) : null;
}
function omitRev(document) {
if (!document)
return null;
const { _rev, ...doc } = document;
return doc;
}
export {
applyMutations,
commit,
createSharedListener,
documentMutatorMachine,
rebase,
squashDMPStrings,
squashMutationGroups,
toTransactions
};
//# sourceMappingURL=_unstable_machine.js.map