a tool for shared writing and social publishing
1import { DeepReadonly, Replicache, WriteTransaction } from "replicache";
2import type { Fact, ReplicacheMutators } from ".";
3import type { Attribute, Attributes, FilterAttributes } from "./attributes";
4import { SupabaseClient } from "@supabase/supabase-js";
5import { Database } from "supabase/database.types";
6import { generateKeyBetween } from "fractional-indexing";
7import { v7 } from "uuid";
8
9export type MutationContext = {
10 permission_token_id: string;
11 createEntity: (args: {
12 entityID: string;
13 permission_set: string;
14 }) => Promise<boolean>;
15 scanIndex: {
16 eav: <A extends Attribute>(
17 entity: string,
18 attribute: A,
19 ) => Promise<DeepReadonly<Fact<A>[]>>;
20 };
21 deleteEntity: (entity: string) => Promise<void>;
22 assertFact: <A extends Attribute>(
23 f: Omit<Fact<A>, "id"> & { id?: string },
24 ) => Promise<void>;
25 retractFact: (id: string) => Promise<void>;
26 runOnServer(
27 cb: (ctx: { supabase: SupabaseClient<Database> }) => Promise<void>,
28 ): Promise<void>;
29 runOnClient(
30 cb: (ctx: {
31 supabase: SupabaseClient<Database>;
32 tx: WriteTransaction;
33 }) => Promise<void>,
34 ): Promise<void>;
35};
36
37type Mutation<T> = (
38 args: T & { ignoreUndo?: true },
39 ctx: MutationContext,
40) => Promise<void>;
41
42const addCanvasBlock: Mutation<{
43 parent: string;
44 permission_set: string;
45 factID: string;
46 type: Fact<"block/type">["data"]["value"];
47 newEntityID: string;
48 position: { x: number; y: number };
49}> = async (args, ctx) => {
50 await ctx.createEntity({
51 entityID: args.newEntityID,
52 permission_set: args.permission_set,
53 });
54 await ctx.assertFact({
55 entity: args.parent,
56 id: args.factID,
57 data: {
58 type: "spatial-reference",
59 value: args.newEntityID,
60 position: args.position,
61 },
62 attribute: "canvas/block",
63 });
64 await ctx.assertFact({
65 entity: args.newEntityID,
66 data: { type: "block-type-union", value: args.type },
67 attribute: "block/type",
68 });
69};
70
71const addBlock: Mutation<{
72 parent: string;
73 permission_set: string;
74 factID: string;
75 type: Fact<"block/type">["data"]["value"];
76 newEntityID: string;
77 position: string;
78}> = async (args, ctx) => {
79 await ctx.createEntity({
80 entityID: args.newEntityID,
81 permission_set: args.permission_set,
82 });
83 await ctx.assertFact({
84 entity: args.parent,
85 id: args.factID,
86 data: {
87 type: "ordered-reference",
88 value: args.newEntityID,
89 position: args.position,
90 },
91 attribute: "card/block",
92 });
93 await ctx.assertFact({
94 entity: args.newEntityID,
95 data: { type: "block-type-union", value: args.type },
96 attribute: "block/type",
97 });
98};
99
100const addLastBlock: Mutation<{
101 parent: string;
102 factID: string;
103 entity: string;
104}> = async (args, ctx) => {
105 let children = await ctx.scanIndex.eav(args.parent, "card/block");
106 let lastChild = children.toSorted((a, b) =>
107 a.data.position > b.data.position ? 1 : -1,
108 )[children.length - 1];
109 await ctx.assertFact({
110 entity: args.parent,
111 id: args.factID,
112 attribute: "card/block",
113 data: {
114 type: "ordered-reference",
115 value: args.entity,
116 position: generateKeyBetween(lastChild?.data.position || null, null),
117 },
118 });
119};
120
121const moveBlock: Mutation<{
122 oldParent: string;
123 block: string;
124 newParent: string;
125 position:
126 | { type: "first" }
127 | { type: "end" }
128 | { type: "after"; entity: string };
129}> = async (args, ctx) => {
130 let children = (
131 await ctx.scanIndex.eav(args.oldParent, "card/block")
132 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
133 let newSiblings = (
134 await ctx.scanIndex.eav(args.newParent, "card/block")
135 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
136 let block = children.find((f) => f.data.value === args.block);
137 if (!block) return;
138 await ctx.retractFact(block.id);
139 let newPosition;
140 let pos = args.position;
141 switch (pos.type) {
142 case "first": {
143 newPosition = generateKeyBetween(
144 null,
145 newSiblings[0]?.data.position || null,
146 );
147 break;
148 }
149 case "end": {
150 newPosition = generateKeyBetween(
151 newSiblings[newSiblings.length - 1]?.data.position || null,
152 null,
153 );
154 break;
155 }
156 case "after": {
157 let index = newSiblings.findIndex((f) => f.data.value == pos?.entity);
158 newPosition = generateKeyBetween(
159 newSiblings[index]?.data.position || null,
160 newSiblings[index + 1]?.data.position || null,
161 );
162 }
163 }
164 await ctx.assertFact({
165 id: block.id,
166 entity: args.newParent,
167 attribute: "card/block",
168 data: {
169 type: "ordered-reference",
170 value: block.data.value,
171 position: newPosition,
172 },
173 });
174};
175const moveChildren: Mutation<{
176 oldParent: string;
177 newParent: string;
178 after: string | null;
179}> = async (args, ctx) => {
180 let children = (
181 await ctx.scanIndex.eav(args.oldParent, "card/block")
182 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
183 let newSiblings = (
184 await ctx.scanIndex.eav(args.newParent, "card/block")
185 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
186 let index = newSiblings.findIndex((f) => f.data.value === args.after);
187 let newPosition = generateKeyBetween(
188 newSiblings[index]?.data.position || null,
189 newSiblings[index + 1]?.data.position || null,
190 );
191 for (let child of children) {
192 await ctx.retractFact(child.id);
193 await ctx.assertFact({
194 id: child.id,
195 entity: args.newParent,
196 attribute: "card/block",
197 data: {
198 type: "ordered-reference",
199 value: child.data.value,
200 position: newPosition,
201 },
202 });
203 newPosition = generateKeyBetween(
204 newPosition,
205 newSiblings[index + 1]?.data.position || null,
206 );
207 }
208};
209
210const outdentBlock: Mutation<{
211 oldParent: string;
212 newParent: string;
213 after: string;
214 block: string;
215 excludeFromSiblings?: string[];
216}> = async (args, ctx) => {
217 //we should be able to get normal siblings here as we care only about one level
218 let newSiblings = (
219 await ctx.scanIndex.eav(args.newParent, "card/block")
220 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
221 let currentSiblings = (
222 await ctx.scanIndex.eav(args.oldParent, "card/block")
223 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
224
225 let currentFactIndex = currentSiblings.findIndex(
226 (f) => f.data.value === args.block,
227 );
228 if (currentFactIndex === -1) return;
229 // Filter out blocks that are being processed separately (e.g., in multi-select outdent)
230 let excludeSet = new Set(args.excludeFromSiblings || []);
231 let currentSiblingsAfter = currentSiblings
232 .slice(currentFactIndex + 1)
233 .filter((sib) => !excludeSet.has(sib.data.value));
234 let currentChildren = (
235 await ctx.scanIndex.eav(args.block, "card/block")
236 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
237 let lastPosition =
238 currentChildren[currentChildren.length - 1]?.data.position || null;
239 await ctx.retractFact(currentSiblings[currentFactIndex].id);
240 for (let sib of currentSiblingsAfter) {
241 await ctx.retractFact(sib.id);
242 lastPosition = generateKeyBetween(lastPosition, null);
243 await ctx.assertFact({
244 entity: args.block,
245 id: sib.id,
246 attribute: "card/block",
247 data: {
248 type: "ordered-reference",
249 position: lastPosition,
250 value: sib.data.value,
251 },
252 });
253 }
254
255 let index = newSiblings.findIndex((f) => f.data.value === args.after);
256 if (index === -1) return;
257 let newPosition = generateKeyBetween(
258 newSiblings[index]?.data.position,
259 newSiblings[index + 1]?.data.position || null,
260 );
261 await ctx.assertFact({
262 id: currentSiblings[currentFactIndex].id,
263 entity: args.newParent,
264 attribute: "card/block",
265 data: {
266 type: "ordered-reference",
267 position: newPosition,
268 value: args.block,
269 },
270 });
271};
272
273const addPageLinkBlock: Mutation<{
274 type: "canvas" | "doc";
275 permission_set: string;
276 blockEntity: string;
277 firstBlockEntity: string;
278 firstBlockFactID: string;
279 pageEntity: string;
280}> = async (args, ctx) => {
281 await ctx.createEntity({
282 entityID: args.pageEntity,
283 permission_set: args.permission_set,
284 });
285 await ctx.assertFact({
286 entity: args.blockEntity,
287 attribute: "block/card",
288 data: { type: "reference", value: args.pageEntity },
289 });
290 await ctx.assertFact({
291 attribute: "page/type",
292 entity: args.pageEntity,
293 data: { type: "page-type-union", value: args.type },
294 });
295 await addBlock(
296 {
297 factID: args.firstBlockFactID,
298 permission_set: args.permission_set,
299 newEntityID: args.firstBlockEntity,
300 type: "heading",
301 parent: args.pageEntity,
302 position: "a0",
303 },
304 ctx,
305 );
306};
307
308const retractFact: Mutation<{ factID: string }> = async (args, ctx) => {
309 await ctx.retractFact(args.factID);
310};
311
312const removeBlock: Mutation<
313 { blockEntity: string } | { blockEntity: string }[]
314> = async (args, ctx) => {
315 for (let block of [args].flat()) {
316 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image");
317 await ctx.runOnServer(async ({ supabase }) => {
318 if (image) {
319 let paths = image.data.src.split("/");
320 await supabase.storage
321 .from("minilink-user-assets")
322 .remove([paths[paths.length - 1]]);
323
324 // Clear cover image if this block is the cover image
325 // First try leaflets_in_publications
326 const { data: pubResult } = await supabase
327 .from("leaflets_in_publications")
328 .update({ cover_image: null })
329 .eq("leaflet", ctx.permission_token_id)
330 .eq("cover_image", block.blockEntity)
331 .select("leaflet");
332
333 // If no rows updated, try leaflets_to_documents
334 if (!pubResult || pubResult.length === 0) {
335 await supabase
336 .from("leaflets_to_documents")
337 .update({ cover_image: null })
338 .eq("leaflet", ctx.permission_token_id)
339 .eq("cover_image", block.blockEntity);
340 }
341 }
342 });
343 await ctx.runOnClient(async ({ tx }) => {
344 let cache = await caches.open("minilink-user-assets");
345 if (image) {
346 await cache.delete(image.data.src + "?local");
347
348 // Clear cover image in client state if this block was the cover image
349 let currentCoverImage = await tx.get("publication_cover_image");
350 if (currentCoverImage === block.blockEntity) {
351 await tx.set("publication_cover_image", null);
352 }
353 }
354 });
355 await ctx.deleteEntity(block.blockEntity);
356 }
357};
358
359const deleteEntity: Mutation<{ entity: string }> = async (args, ctx) => {
360 await ctx.deleteEntity(args.entity);
361};
362
363export type FactInput = {
364 [k in Attribute]: Omit<Fact<k>, "id"> & { id?: string };
365}[Attribute];
366const assertFact: Mutation<FactInput | Array<FactInput>> = async (
367 args,
368 ctx,
369) => {
370 for (let f of [args].flat()) {
371 await ctx.assertFact(f);
372 }
373};
374
375const increaseHeadingLevel: Mutation<{ entityID: string }> = async (
376 args,
377 ctx,
378) => {
379 let blockType = (await ctx.scanIndex.eav(args.entityID, "block/type"))[0];
380 let headinglevel = (
381 await ctx.scanIndex.eav(args.entityID, "block/heading-level")
382 )[0];
383 if (blockType?.data.value !== "heading")
384 await ctx.assertFact({
385 entity: args.entityID,
386 attribute: "block/type",
387 data: { type: "block-type-union", value: "heading" },
388 });
389
390 if (!headinglevel || blockType?.data.value !== "heading") {
391 return await ctx.assertFact({
392 entity: args.entityID,
393 attribute: "block/heading-level",
394 data: { type: "number", value: 1 },
395 });
396 }
397 if (headinglevel?.data.value === 3) return;
398 return await ctx.assertFact({
399 entity: args.entityID,
400 attribute: "block/heading-level",
401 data: { type: "number", value: headinglevel.data.value + 1 },
402 });
403};
404
405const moveBlockUp: Mutation<{ entityID: string; parent: string }> = async (
406 args,
407 ctx,
408) => {
409 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted(
410 (a, b) => (a.data.position > b.data.position ? 1 : -1),
411 );
412 let index = children.findIndex((f) => f.data.value === args.entityID);
413 if (index === -1) return;
414 let next = children[index - 1];
415 if (!next) return;
416 await ctx.retractFact(children[index].id);
417 await ctx.assertFact({
418 id: children[index].id,
419 entity: args.parent,
420 attribute: "card/block",
421 data: {
422 type: "ordered-reference",
423 position: generateKeyBetween(
424 children[index - 2]?.data.position || null,
425 next.data.position,
426 ),
427 value: args.entityID,
428 },
429 });
430};
431const moveBlockDown: Mutation<{
432 entityID: string;
433 parent: string;
434 permission_set?: string;
435}> = async (args, ctx) => {
436 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted(
437 (a, b) => (a.data.position > b.data.position ? 1 : -1),
438 );
439 let index = children.findIndex((f) => f.data.value === args.entityID);
440 if (index === -1) return;
441 let next = children[index + 1];
442 if (!next) {
443 // If this is the last block, create a new empty block above it using the addBlock helper
444 if (!args.permission_set) return; // Can't create block without permission_set
445
446 let newEntityID = v7();
447 let previousBlock = children[index - 1];
448 let position = generateKeyBetween(
449 previousBlock?.data.position || null,
450 children[index].data.position,
451 );
452
453 // Call the addBlock mutation helper directly
454 await addBlock(
455 {
456 parent: args.parent,
457 permission_set: args.permission_set,
458 factID: v7(),
459 type: "text",
460 newEntityID: newEntityID,
461 position: position,
462 },
463 ctx,
464 );
465 return;
466 }
467 await ctx.retractFact(children[index].id);
468 await ctx.assertFact({
469 id: children[index].id,
470 entity: args.parent,
471 attribute: "card/block",
472 data: {
473 type: "ordered-reference",
474 position: generateKeyBetween(
475 next.data.position,
476 children[index + 2]?.data.position || null,
477 ),
478 value: args.entityID,
479 },
480 });
481};
482
483const createEntity: Mutation<
484 Array<{ entityID: string; permission_set: string }>
485> = async (args, ctx) => {
486 for (let newentity of args) {
487 await ctx.createEntity(newentity);
488 }
489};
490
491const createDraft: Mutation<{
492 mailboxEntity: string;
493 newEntity: string;
494 permission_set: string;
495 firstBlockEntity: string;
496 firstBlockFactID: string;
497}> = async (args, ctx) => {
498 let [existingDraft] = await ctx.scanIndex.eav(
499 args.mailboxEntity,
500 "mailbox/draft",
501 );
502 if (existingDraft) return;
503 await ctx.createEntity({
504 entityID: args.newEntity,
505 permission_set: args.permission_set,
506 });
507 await ctx.assertFact({
508 entity: args.mailboxEntity,
509 attribute: "mailbox/draft",
510 data: { type: "reference", value: args.newEntity },
511 });
512 await addBlock(
513 {
514 factID: args.firstBlockFactID,
515 permission_set: args.permission_set,
516 newEntityID: args.firstBlockEntity,
517 type: "text",
518 parent: args.newEntity,
519 position: "a0",
520 },
521 ctx,
522 );
523};
524
525const archiveDraft: Mutation<{
526 mailboxEntity: string;
527 archiveEntity: string;
528 newBlockEntity: string;
529 entity_set: string;
530}> = async (args, ctx) => {
531 let [existingDraft] = await ctx.scanIndex.eav(
532 args.mailboxEntity,
533 "mailbox/draft",
534 );
535 if (!existingDraft) return;
536
537 let [archive] = await ctx.scanIndex.eav(
538 args.mailboxEntity,
539 "mailbox/archive",
540 );
541 let archiveEntity = archive?.data.value;
542 if (!archive) {
543 archiveEntity = args.archiveEntity;
544 await ctx.createEntity({
545 entityID: archiveEntity,
546 permission_set: args.entity_set,
547 });
548 await ctx.assertFact({
549 entity: args.mailboxEntity,
550 attribute: "mailbox/archive",
551 data: { type: "reference", value: archiveEntity },
552 });
553 }
554
555 let archiveChildren = await ctx.scanIndex.eav(archiveEntity, "card/block");
556 let firstChild = archiveChildren.toSorted((a, b) =>
557 a.data.position > b.data.position ? 1 : -1,
558 )[0];
559
560 await ctx.createEntity({
561 entityID: args.newBlockEntity,
562 permission_set: args.entity_set,
563 });
564 await ctx.assertFact({
565 entity: args.newBlockEntity,
566 attribute: "block/type",
567 data: { type: "block-type-union", value: "card" },
568 });
569
570 await ctx.assertFact({
571 entity: args.newBlockEntity,
572 attribute: "block/card",
573 data: { type: "reference", value: existingDraft.data.value },
574 });
575
576 await ctx.assertFact({
577 entity: archiveEntity,
578 attribute: "card/block",
579 data: {
580 type: "ordered-reference",
581 value: args.newBlockEntity,
582 position: generateKeyBetween(null, firstChild?.data.position),
583 },
584 });
585
586 await ctx.retractFact(existingDraft.id);
587};
588
589const retractAttribute: Mutation<{
590 entity: string;
591 attribute:
592 | keyof FilterAttributes<{ cardinality: "one" }>
593 | Array<keyof FilterAttributes<{ cardinality: "one" }>>;
594}> = async (args, ctx) => {
595 for (let a of [args.attribute].flat()) {
596 let fact = (await ctx.scanIndex.eav(args.entity, a))[0];
597 if (fact) await ctx.retractFact(fact.id);
598 }
599};
600
601const toggleTodoState: Mutation<{ entityID: string }> = async (args, ctx) => {
602 let [checked] = await ctx.scanIndex.eav(args.entityID, "block/check-list");
603 if (!checked) {
604 await ctx.assertFact({
605 entity: args.entityID,
606 attribute: "block/check-list",
607 data: { type: "boolean", value: false },
608 });
609 } else if (!checked.data.value) {
610 await ctx.assertFact({
611 entity: args.entityID,
612 attribute: "block/check-list",
613 data: { type: "boolean", value: true },
614 });
615 } else {
616 await ctx.retractFact(checked.id);
617 }
618};
619
620const addPollOption: Mutation<{
621 pollEntity: string;
622 pollOptionEntity: string;
623 pollOptionName: string;
624 permission_set: string;
625 factID: string;
626}> = async (args, ctx) => {
627 await ctx.createEntity({
628 entityID: args.pollOptionEntity,
629 permission_set: args.permission_set,
630 });
631
632 await ctx.assertFact({
633 entity: args.pollOptionEntity,
634 attribute: "poll-option/name",
635 data: { type: "string", value: args.pollOptionName },
636 });
637
638 let children = await ctx.scanIndex.eav(args.pollEntity, "poll/options");
639 let lastChild = children.toSorted((a, b) =>
640 a.data.position > b.data.position ? 1 : -1,
641 )[children.length - 1];
642
643 await ctx.assertFact({
644 entity: args.pollEntity,
645 id: args.factID,
646 attribute: "poll/options",
647 data: {
648 type: "ordered-reference",
649 value: args.pollOptionEntity,
650 position: generateKeyBetween(lastChild?.data.position || null, null),
651 },
652 });
653};
654
655const removePollOption: Mutation<{
656 optionEntity: string;
657}> = async (args, ctx) => {
658 await ctx.deleteEntity(args.optionEntity);
659};
660
661const updatePublicationDraft: Mutation<{
662 title?: string;
663 description?: string;
664 tags?: string[];
665 cover_image?: string | null;
666 localPublishedAt?: string | null;
667 preferences?: {
668 showComments?: boolean;
669 showMentions?: boolean;
670 showRecommends?: boolean;
671 } | null;
672}> = async (args, ctx) => {
673 await ctx.runOnServer(async (serverCtx) => {
674 const updates: {
675 description?: string;
676 title?: string;
677 tags?: string[];
678 cover_image?: string | null;
679 preferences?: {
680 showComments?: boolean;
681 showMentions?: boolean;
682 showRecommends?: boolean;
683 } | null;
684 } = {};
685 if (args.description !== undefined) updates.description = args.description;
686 if (args.title !== undefined) updates.title = args.title;
687 if (args.tags !== undefined) updates.tags = args.tags;
688 if (args.cover_image !== undefined) updates.cover_image = args.cover_image;
689 if (args.preferences !== undefined) updates.preferences = args.preferences;
690
691 if (Object.keys(updates).length > 0) {
692 // First try to update leaflets_in_publications (for publications)
693 const { data: pubResult } = await serverCtx.supabase
694 .from("leaflets_in_publications")
695 .update(updates)
696 .eq("leaflet", ctx.permission_token_id)
697 .select("leaflet");
698
699 // If no rows were updated in leaflets_in_publications,
700 // try leaflets_to_documents (for standalone documents)
701 if (!pubResult || pubResult.length === 0) {
702 await serverCtx.supabase
703 .from("leaflets_to_documents")
704 .update(updates)
705 .eq("leaflet", ctx.permission_token_id);
706 }
707 }
708 });
709 await ctx.runOnClient(async ({ tx }) => {
710 if (args.title !== undefined) await tx.set("publication_title", args.title);
711 if (args.description !== undefined)
712 await tx.set("publication_description", args.description);
713 if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
714 if (args.cover_image !== undefined)
715 await tx.set("publication_cover_image", args.cover_image);
716 if (args.localPublishedAt !== undefined)
717 await tx.set("publication_local_published_at", args.localPublishedAt);
718 if (args.preferences !== undefined)
719 await tx.set("post_preferences", args.preferences);
720 });
721};
722
723export const mutations = {
724 retractAttribute,
725 addBlock,
726 addCanvasBlock,
727 addLastBlock,
728 outdentBlock,
729 moveBlockUp,
730 moveBlockDown,
731 addPageLinkBlock,
732 moveBlock,
733 assertFact,
734 retractFact,
735 removeBlock,
736 deleteEntity,
737 moveChildren,
738 increaseHeadingLevel,
739 archiveDraft,
740 toggleTodoState,
741 createDraft,
742 createEntity,
743 addPollOption,
744 removePollOption,
745 updatePublicationDraft,
746};