a tool for shared writing and social publishing
at update/reader 728 lines 21 kB view raw
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}> = async (args, ctx) => { 216 //we should be able to get normal siblings here as we care only about one level 217 let newSiblings = ( 218 await ctx.scanIndex.eav(args.newParent, "card/block") 219 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 220 let currentSiblings = ( 221 await ctx.scanIndex.eav(args.oldParent, "card/block") 222 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 223 224 let currentFactIndex = currentSiblings.findIndex( 225 (f) => f.data.value === args.block, 226 ); 227 if (currentFactIndex === -1) return; 228 let currentSiblingsAfter = currentSiblings.slice(currentFactIndex + 1); 229 let currentChildren = ( 230 await ctx.scanIndex.eav(args.block, "card/block") 231 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 232 let lastPosition = 233 currentChildren[currentChildren.length - 1]?.data.position || null; 234 await ctx.retractFact(currentSiblings[currentFactIndex].id); 235 for (let sib of currentSiblingsAfter) { 236 await ctx.retractFact(sib.id); 237 lastPosition = generateKeyBetween(lastPosition, null); 238 await ctx.assertFact({ 239 entity: args.block, 240 id: sib.id, 241 attribute: "card/block", 242 data: { 243 type: "ordered-reference", 244 position: lastPosition, 245 value: sib.data.value, 246 }, 247 }); 248 } 249 250 let index = newSiblings.findIndex((f) => f.data.value === args.after); 251 if (index === -1) return; 252 let newPosition = generateKeyBetween( 253 newSiblings[index]?.data.position, 254 newSiblings[index + 1]?.data.position || null, 255 ); 256 await ctx.assertFact({ 257 id: currentSiblings[currentFactIndex].id, 258 entity: args.newParent, 259 attribute: "card/block", 260 data: { 261 type: "ordered-reference", 262 position: newPosition, 263 value: args.block, 264 }, 265 }); 266}; 267 268const addPageLinkBlock: Mutation<{ 269 type: "canvas" | "doc"; 270 permission_set: string; 271 blockEntity: string; 272 firstBlockEntity: string; 273 firstBlockFactID: string; 274 pageEntity: string; 275}> = async (args, ctx) => { 276 await ctx.createEntity({ 277 entityID: args.pageEntity, 278 permission_set: args.permission_set, 279 }); 280 await ctx.assertFact({ 281 entity: args.blockEntity, 282 attribute: "block/card", 283 data: { type: "reference", value: args.pageEntity }, 284 }); 285 await ctx.assertFact({ 286 attribute: "page/type", 287 entity: args.pageEntity, 288 data: { type: "page-type-union", value: args.type }, 289 }); 290 await addBlock( 291 { 292 factID: args.firstBlockFactID, 293 permission_set: args.permission_set, 294 newEntityID: args.firstBlockEntity, 295 type: "heading", 296 parent: args.pageEntity, 297 position: "a0", 298 }, 299 ctx, 300 ); 301}; 302 303const retractFact: Mutation<{ factID: string }> = async (args, ctx) => { 304 await ctx.retractFact(args.factID); 305}; 306 307const removeBlock: Mutation< 308 { blockEntity: string } | { blockEntity: string }[] 309> = async (args, ctx) => { 310 for (let block of [args].flat()) { 311 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 312 await ctx.runOnServer(async ({ supabase }) => { 313 if (image) { 314 let paths = image.data.src.split("/"); 315 await supabase.storage 316 .from("minilink-user-assets") 317 .remove([paths[paths.length - 1]]); 318 319 // Clear cover image if this block is the cover image 320 // First try leaflets_in_publications 321 const { data: pubResult } = await supabase 322 .from("leaflets_in_publications") 323 .update({ cover_image: null }) 324 .eq("leaflet", ctx.permission_token_id) 325 .eq("cover_image", block.blockEntity) 326 .select("leaflet"); 327 328 // If no rows updated, try leaflets_to_documents 329 if (!pubResult || pubResult.length === 0) { 330 await supabase 331 .from("leaflets_to_documents") 332 .update({ cover_image: null }) 333 .eq("leaflet", ctx.permission_token_id) 334 .eq("cover_image", block.blockEntity); 335 } 336 } 337 }); 338 await ctx.runOnClient(async ({ tx }) => { 339 let cache = await caches.open("minilink-user-assets"); 340 if (image) { 341 await cache.delete(image.data.src + "?local"); 342 343 // Clear cover image in client state if this block was the cover image 344 let currentCoverImage = await tx.get("publication_cover_image"); 345 if (currentCoverImage === block.blockEntity) { 346 await tx.set("publication_cover_image", null); 347 } 348 } 349 }); 350 await ctx.deleteEntity(block.blockEntity); 351 } 352}; 353 354const deleteEntity: Mutation<{ entity: string }> = async (args, ctx) => { 355 await ctx.deleteEntity(args.entity); 356}; 357 358export type FactInput = { 359 [k in Attribute]: Omit<Fact<k>, "id"> & { id?: string }; 360}[Attribute]; 361const assertFact: Mutation<FactInput | Array<FactInput>> = async ( 362 args, 363 ctx, 364) => { 365 for (let f of [args].flat()) { 366 await ctx.assertFact(f); 367 } 368}; 369 370const increaseHeadingLevel: Mutation<{ entityID: string }> = async ( 371 args, 372 ctx, 373) => { 374 let blockType = (await ctx.scanIndex.eav(args.entityID, "block/type"))[0]; 375 let headinglevel = ( 376 await ctx.scanIndex.eav(args.entityID, "block/heading-level") 377 )[0]; 378 if (blockType?.data.value !== "heading") 379 await ctx.assertFact({ 380 entity: args.entityID, 381 attribute: "block/type", 382 data: { type: "block-type-union", value: "heading" }, 383 }); 384 385 if (!headinglevel || blockType?.data.value !== "heading") { 386 return await ctx.assertFact({ 387 entity: args.entityID, 388 attribute: "block/heading-level", 389 data: { type: "number", value: 1 }, 390 }); 391 } 392 if (headinglevel?.data.value === 3) return; 393 return await ctx.assertFact({ 394 entity: args.entityID, 395 attribute: "block/heading-level", 396 data: { type: "number", value: headinglevel.data.value + 1 }, 397 }); 398}; 399 400const moveBlockUp: Mutation<{ entityID: string; parent: string }> = async ( 401 args, 402 ctx, 403) => { 404 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 405 (a, b) => (a.data.position > b.data.position ? 1 : -1), 406 ); 407 let index = children.findIndex((f) => f.data.value === args.entityID); 408 if (index === -1) return; 409 let next = children[index - 1]; 410 if (!next) return; 411 await ctx.retractFact(children[index].id); 412 await ctx.assertFact({ 413 id: children[index].id, 414 entity: args.parent, 415 attribute: "card/block", 416 data: { 417 type: "ordered-reference", 418 position: generateKeyBetween( 419 children[index - 2]?.data.position || null, 420 next.data.position, 421 ), 422 value: args.entityID, 423 }, 424 }); 425}; 426const moveBlockDown: Mutation<{ 427 entityID: string; 428 parent: string; 429 permission_set?: string; 430}> = async (args, ctx) => { 431 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 432 (a, b) => (a.data.position > b.data.position ? 1 : -1), 433 ); 434 let index = children.findIndex((f) => f.data.value === args.entityID); 435 if (index === -1) return; 436 let next = children[index + 1]; 437 if (!next) { 438 // If this is the last block, create a new empty block above it using the addBlock helper 439 if (!args.permission_set) return; // Can't create block without permission_set 440 441 let newEntityID = v7(); 442 let previousBlock = children[index - 1]; 443 let position = generateKeyBetween( 444 previousBlock?.data.position || null, 445 children[index].data.position, 446 ); 447 448 // Call the addBlock mutation helper directly 449 await addBlock( 450 { 451 parent: args.parent, 452 permission_set: args.permission_set, 453 factID: v7(), 454 type: "text", 455 newEntityID: newEntityID, 456 position: position, 457 }, 458 ctx, 459 ); 460 return; 461 } 462 await ctx.retractFact(children[index].id); 463 await ctx.assertFact({ 464 id: children[index].id, 465 entity: args.parent, 466 attribute: "card/block", 467 data: { 468 type: "ordered-reference", 469 position: generateKeyBetween( 470 next.data.position, 471 children[index + 2]?.data.position || null, 472 ), 473 value: args.entityID, 474 }, 475 }); 476}; 477 478const createEntity: Mutation< 479 Array<{ entityID: string; permission_set: string }> 480> = async (args, ctx) => { 481 for (let newentity of args) { 482 await ctx.createEntity(newentity); 483 } 484}; 485 486const createDraft: Mutation<{ 487 mailboxEntity: string; 488 newEntity: string; 489 permission_set: string; 490 firstBlockEntity: string; 491 firstBlockFactID: string; 492}> = async (args, ctx) => { 493 let [existingDraft] = await ctx.scanIndex.eav( 494 args.mailboxEntity, 495 "mailbox/draft", 496 ); 497 if (existingDraft) return; 498 await ctx.createEntity({ 499 entityID: args.newEntity, 500 permission_set: args.permission_set, 501 }); 502 await ctx.assertFact({ 503 entity: args.mailboxEntity, 504 attribute: "mailbox/draft", 505 data: { type: "reference", value: args.newEntity }, 506 }); 507 await addBlock( 508 { 509 factID: args.firstBlockFactID, 510 permission_set: args.permission_set, 511 newEntityID: args.firstBlockEntity, 512 type: "text", 513 parent: args.newEntity, 514 position: "a0", 515 }, 516 ctx, 517 ); 518}; 519 520const archiveDraft: Mutation<{ 521 mailboxEntity: string; 522 archiveEntity: string; 523 newBlockEntity: string; 524 entity_set: string; 525}> = async (args, ctx) => { 526 let [existingDraft] = await ctx.scanIndex.eav( 527 args.mailboxEntity, 528 "mailbox/draft", 529 ); 530 if (!existingDraft) return; 531 532 let [archive] = await ctx.scanIndex.eav( 533 args.mailboxEntity, 534 "mailbox/archive", 535 ); 536 let archiveEntity = archive?.data.value; 537 if (!archive) { 538 archiveEntity = args.archiveEntity; 539 await ctx.createEntity({ 540 entityID: archiveEntity, 541 permission_set: args.entity_set, 542 }); 543 await ctx.assertFact({ 544 entity: args.mailboxEntity, 545 attribute: "mailbox/archive", 546 data: { type: "reference", value: archiveEntity }, 547 }); 548 } 549 550 let archiveChildren = await ctx.scanIndex.eav(archiveEntity, "card/block"); 551 let firstChild = archiveChildren.toSorted((a, b) => 552 a.data.position > b.data.position ? 1 : -1, 553 )[0]; 554 555 await ctx.createEntity({ 556 entityID: args.newBlockEntity, 557 permission_set: args.entity_set, 558 }); 559 await ctx.assertFact({ 560 entity: args.newBlockEntity, 561 attribute: "block/type", 562 data: { type: "block-type-union", value: "card" }, 563 }); 564 565 await ctx.assertFact({ 566 entity: args.newBlockEntity, 567 attribute: "block/card", 568 data: { type: "reference", value: existingDraft.data.value }, 569 }); 570 571 await ctx.assertFact({ 572 entity: archiveEntity, 573 attribute: "card/block", 574 data: { 575 type: "ordered-reference", 576 value: args.newBlockEntity, 577 position: generateKeyBetween(null, firstChild?.data.position), 578 }, 579 }); 580 581 await ctx.retractFact(existingDraft.id); 582}; 583 584const retractAttribute: Mutation<{ 585 entity: string; 586 attribute: 587 | keyof FilterAttributes<{ cardinality: "one" }> 588 | Array<keyof FilterAttributes<{ cardinality: "one" }>>; 589}> = async (args, ctx) => { 590 for (let a of [args.attribute].flat()) { 591 let fact = (await ctx.scanIndex.eav(args.entity, a))[0]; 592 if (fact) await ctx.retractFact(fact.id); 593 } 594}; 595 596const toggleTodoState: Mutation<{ entityID: string }> = async (args, ctx) => { 597 let [checked] = await ctx.scanIndex.eav(args.entityID, "block/check-list"); 598 if (!checked) { 599 await ctx.assertFact({ 600 entity: args.entityID, 601 attribute: "block/check-list", 602 data: { type: "boolean", value: false }, 603 }); 604 } else if (!checked.data.value) { 605 await ctx.assertFact({ 606 entity: args.entityID, 607 attribute: "block/check-list", 608 data: { type: "boolean", value: true }, 609 }); 610 } else { 611 await ctx.retractFact(checked.id); 612 } 613}; 614 615const addPollOption: Mutation<{ 616 pollEntity: string; 617 pollOptionEntity: string; 618 pollOptionName: string; 619 permission_set: string; 620 factID: string; 621}> = async (args, ctx) => { 622 await ctx.createEntity({ 623 entityID: args.pollOptionEntity, 624 permission_set: args.permission_set, 625 }); 626 627 await ctx.assertFact({ 628 entity: args.pollOptionEntity, 629 attribute: "poll-option/name", 630 data: { type: "string", value: args.pollOptionName }, 631 }); 632 633 let children = await ctx.scanIndex.eav(args.pollEntity, "poll/options"); 634 let lastChild = children.toSorted((a, b) => 635 a.data.position > b.data.position ? 1 : -1, 636 )[children.length - 1]; 637 638 await ctx.assertFact({ 639 entity: args.pollEntity, 640 id: args.factID, 641 attribute: "poll/options", 642 data: { 643 type: "ordered-reference", 644 value: args.pollOptionEntity, 645 position: generateKeyBetween(lastChild?.data.position || null, null), 646 }, 647 }); 648}; 649 650const removePollOption: Mutation<{ 651 optionEntity: string; 652}> = async (args, ctx) => { 653 await ctx.deleteEntity(args.optionEntity); 654}; 655 656const updatePublicationDraft: Mutation<{ 657 title?: string; 658 description?: string; 659 tags?: string[]; 660 cover_image?: string | null; 661 localPublishedAt?: string | null; 662}> = async (args, ctx) => { 663 await ctx.runOnServer(async (serverCtx) => { 664 const updates: { 665 description?: string; 666 title?: string; 667 tags?: string[]; 668 cover_image?: string | null; 669 } = {}; 670 if (args.description !== undefined) updates.description = args.description; 671 if (args.title !== undefined) updates.title = args.title; 672 if (args.tags !== undefined) updates.tags = args.tags; 673 if (args.cover_image !== undefined) updates.cover_image = args.cover_image; 674 675 if (Object.keys(updates).length > 0) { 676 // First try to update leaflets_in_publications (for publications) 677 const { data: pubResult } = await serverCtx.supabase 678 .from("leaflets_in_publications") 679 .update(updates) 680 .eq("leaflet", ctx.permission_token_id) 681 .select("leaflet"); 682 683 // If no rows were updated in leaflets_in_publications, 684 // try leaflets_to_documents (for standalone documents) 685 if (!pubResult || pubResult.length === 0) { 686 await serverCtx.supabase 687 .from("leaflets_to_documents") 688 .update(updates) 689 .eq("leaflet", ctx.permission_token_id); 690 } 691 } 692 }); 693 await ctx.runOnClient(async ({ tx }) => { 694 if (args.title !== undefined) await tx.set("publication_title", args.title); 695 if (args.description !== undefined) 696 await tx.set("publication_description", args.description); 697 if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 698 if (args.cover_image !== undefined) 699 await tx.set("publication_cover_image", args.cover_image); 700 if (args.localPublishedAt !== undefined) 701 await tx.set("publication_local_published_at", args.localPublishedAt); 702 }); 703}; 704 705export const mutations = { 706 retractAttribute, 707 addBlock, 708 addCanvasBlock, 709 addLastBlock, 710 outdentBlock, 711 moveBlockUp, 712 moveBlockDown, 713 addPageLinkBlock, 714 moveBlock, 715 assertFact, 716 retractFact, 717 removeBlock, 718 deleteEntity, 719 moveChildren, 720 increaseHeadingLevel, 721 archiveDraft, 722 toggleTodoState, 723 createDraft, 724 createEntity, 725 addPollOption, 726 removePollOption, 727 updatePublicationDraft, 728};