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