a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add ordered references for blocks

+425 -202
+29 -8
app/[doc_id]/page.tsx
··· 1 1 "use client"; 2 2 import { ReplicacheProvider, useEntity, useReplicache } from "../../replicache"; 3 3 import { TextBlock } from "../../components/TextBlock"; 4 + import { generateKeyBetween } from "fractional-indexing"; 4 5 5 6 export default function DocumentPage(props: { params: { doc_id: string } }) { 6 7 return ( ··· 14 15 15 16 function AddBlock(props: { entityID: string }) { 16 17 let rep = useReplicache(); 18 + let blocks = useEntity(props.entityID, "card/block")?.sort((a, b) => { 19 + return a.data.position > b.data.position ? 1 : -1; 20 + }); 17 21 return ( 18 22 <button 19 23 onClick={() => { 20 24 rep?.rep?.mutate.addBlock({ 21 25 parent: props.entityID, 26 + position: generateKeyBetween(null, blocks[0]?.data.position || null), 22 27 newEntityID: crypto.randomUUID(), 23 28 }); 24 29 }} ··· 32 37 let blocks = useEntity(props.entityID, "card/block"); 33 38 34 39 return ( 35 - <div> 36 - {blocks?.map((f) => { 37 - let data = f.data as { type: "reference"; value: string }; 38 - return <Block key={f.id} entityID={data.value} />; 39 - })} 40 + <div className="mx-auto max-w-3xl"> 41 + {blocks 42 + ?.sort((a, b) => { 43 + return a.data.position > b.data.position ? 1 : -1; 44 + }) 45 + .map((f, index, arr) => { 46 + return ( 47 + <Block 48 + key={f.id} 49 + entityID={f.data.value} 50 + parent={props.entityID} 51 + position={f.data.position} 52 + nextPosition={arr[index + 1]?.data.position || null} 53 + /> 54 + ); 55 + })} 40 56 </div> 41 57 ); 42 58 } 43 59 44 - function Block(props: { entityID: string }) { 60 + function Block(props: { 61 + entityID: string; 62 + parent: string; 63 + position: string; 64 + nextPosition: string | null; 65 + }) { 45 66 return ( 46 - <div className="border p-2"> 47 - <TextBlock entityID={props.entityID} /> 67 + <div className="border p-2 w-full"> 68 + <TextBlock {...props} /> 48 69 </div> 49 70 ); 50 71 }
+70 -19
components/TextBlock.tsx
··· 1 1 import { useRef, useEffect, useState } from "react"; 2 + import { elementId } from "../utils/elementId"; 3 + import { baseKeymap, toggleMark } from "prosemirror-commands"; 4 + import { keymap } from "prosemirror-keymap"; 2 5 import * as Y from "yjs"; 6 + import { ProseMirror } from "@nytimes/react-prosemirror"; 3 7 import * as base64 from "base64-js"; 4 - import { useReplicache, useEntity } from "../replicache"; 8 + import { useReplicache, useEntity, ReplicacheMutators } from "../replicache"; 5 9 6 10 import { EditorState } from "prosemirror-state"; 7 - import { EditorView } from "prosemirror-view"; 8 11 import { schema } from "prosemirror-schema-basic"; 9 12 import { ySyncPlugin } from "y-prosemirror"; 13 + import { Replicache } from "replicache"; 14 + import { generateKeyBetween } from "fractional-indexing"; 10 15 11 - export function TextBlock(props: { entityID: string }) { 12 - let ref = useRef<null | HTMLPreElement>(null); 16 + export function TextBlock(props: { 17 + entityID: string; 18 + parent: string; 19 + position: string; 20 + nextPosition: string | null; 21 + }) { 22 + const [mount, setMount] = useState<HTMLElement | null>(null); 13 23 let value = useYJSValue(props.entityID); 24 + let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 25 + let propsRef = useRef(props); 14 26 useEffect(() => { 15 - let editor = new EditorView(ref.current, { 16 - state: EditorState.create({ 17 - schema, 18 - plugins: [ySyncPlugin(value)], 19 - }), 20 - }); 21 - return () => { 22 - editor.destroy(); 23 - }; 24 - }, [value]); 27 + propsRef.current = props; 28 + }, [props]); 29 + let rep = useReplicache(); 30 + useEffect(() => { 31 + repRef.current = rep.rep; 32 + }, [rep?.rep]); 33 + let [editorState, setEditorState] = useState( 34 + EditorState.create({ 35 + schema, 36 + plugins: [ 37 + ySyncPlugin(value), 38 + keymap({ 39 + "Meta-b": toggleMark(schema.marks.strong), 40 + "Meta-i": toggleMark(schema.marks.em), 41 + Enter: () => { 42 + let newEntityID = crypto.randomUUID(); 43 + repRef.current?.mutate.addBlock({ 44 + newEntityID, 45 + parent: props.parent, 46 + position: generateKeyBetween( 47 + propsRef.current.position, 48 + propsRef.current.nextPosition, 49 + ), 50 + }); 51 + setTimeout(() => { 52 + document 53 + .getElementById(elementId.block(newEntityID).text) 54 + ?.focus(); 55 + }, 100); 56 + return true; 57 + }, 58 + }), 59 + keymap(baseKeymap), 60 + ], 61 + }), 62 + ); 25 63 26 - return <pre ref={ref} />; 64 + return ( 65 + <ProseMirror 66 + mount={mount} 67 + state={editorState} 68 + dispatchTransaction={(tr) => { 69 + setEditorState((s) => s.apply(tr)); 70 + }} 71 + > 72 + <pre 73 + id={elementId.block(props.entityID).text} 74 + className="w-full whitespace-pre-wrap" 75 + ref={setMount} 76 + /> 77 + </ProseMirror> 78 + ); 27 79 } 28 80 29 81 function useYJSValue(entityID: string) { 30 82 const [ydoc] = useState(new Y.Doc()); 31 83 const docStateFromReplicache = useEntity(entityID, "block/text"); 84 + console.log(docStateFromReplicache?.data.value); 32 85 let rep = useReplicache(); 33 86 const yText = ydoc.getXmlFragment("prosemirror"); 34 87 35 - if (docStateFromReplicache?.[0]) { 36 - const update = base64.toByteArray(docStateFromReplicache[0].data.value); 88 + if (docStateFromReplicache) { 89 + const update = base64.toByteArray(docStateFromReplicache.data.value); 37 90 Y.applyUpdateV2(ydoc, update); 38 91 } 39 92 40 93 useEffect(() => { 41 94 if (!rep.rep) return; 42 - console.log("yo"); 43 95 const f = async () => { 44 - console.log(entityID); 45 96 const update = Y.encodeStateAsUpdateV2(ydoc); 46 97 await rep.rep?.mutate.assertFact({ 47 98 entity: entityID,
+87 -27
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, uuid, timestamp, text, bigint, foreignKey, jsonb } from "drizzle-orm/pg-core" 2 - import { sql } from "drizzle-orm" 1 + import { 2 + pgTable, 3 + pgEnum, 4 + uuid, 5 + timestamp, 6 + text, 7 + bigint, 8 + foreignKey, 9 + jsonb, 10 + } from "drizzle-orm/pg-core"; 11 + import { sql } from "drizzle-orm"; 12 + import { Fact } from "../replicache"; 13 + import { Attributes } from "../replicache/attributes"; 3 14 4 - export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) 5 - export const code_challenge_method = pgEnum("code_challenge_method", ['s256', 'plain']) 6 - export const factor_status = pgEnum("factor_status", ['unverified', 'verified']) 7 - export const factor_type = pgEnum("factor_type", ['totp', 'webauthn']) 8 - export const request_status = pgEnum("request_status", ['PENDING', 'SUCCESS', 'ERROR']) 9 - export const key_status = pgEnum("key_status", ['default', 'valid', 'invalid', 'expired']) 10 - export const key_type = pgEnum("key_type", ['aead-ietf', 'aead-det', 'hmacsha512', 'hmacsha256', 'auth', 'shorthash', 'generichash', 'kdf', 'secretbox', 'secretstream', 'stream_xchacha20']) 11 - export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR']) 12 - export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 13 - 15 + export const aal_level = pgEnum("aal_level", ["aal1", "aal2", "aal3"]); 16 + export const code_challenge_method = pgEnum("code_challenge_method", [ 17 + "s256", 18 + "plain", 19 + ]); 20 + export const factor_status = pgEnum("factor_status", [ 21 + "unverified", 22 + "verified", 23 + ]); 24 + export const factor_type = pgEnum("factor_type", ["totp", "webauthn"]); 25 + export const request_status = pgEnum("request_status", [ 26 + "PENDING", 27 + "SUCCESS", 28 + "ERROR", 29 + ]); 30 + export const key_status = pgEnum("key_status", [ 31 + "default", 32 + "valid", 33 + "invalid", 34 + "expired", 35 + ]); 36 + export const key_type = pgEnum("key_type", [ 37 + "aead-ietf", 38 + "aead-det", 39 + "hmacsha512", 40 + "hmacsha256", 41 + "auth", 42 + "shorthash", 43 + "generichash", 44 + "kdf", 45 + "secretbox", 46 + "secretstream", 47 + "stream_xchacha20", 48 + ]); 49 + export const action = pgEnum("action", [ 50 + "INSERT", 51 + "UPDATE", 52 + "DELETE", 53 + "TRUNCATE", 54 + "ERROR", 55 + ]); 56 + export const equality_op = pgEnum("equality_op", [ 57 + "eq", 58 + "neq", 59 + "lt", 60 + "lte", 61 + "gt", 62 + "gte", 63 + "in", 64 + ]); 14 65 15 66 export const entities = pgTable("entities", { 16 - id: uuid("id").defaultRandom().primaryKey().notNull(), 17 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 67 + id: uuid("id").defaultRandom().primaryKey().notNull(), 68 + created_at: timestamp("created_at", { withTimezone: true, mode: "string" }) 69 + .defaultNow() 70 + .notNull(), 18 71 }); 19 72 20 73 export const replicache_clients = pgTable("replicache_clients", { 21 - client_id: text("client_id").primaryKey().notNull(), 22 - client_group: text("client_group").notNull(), 23 - // You can use { mode: "bigint" } if numbers are exceeding js number limitations 24 - last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), 74 + client_id: text("client_id").primaryKey().notNull(), 75 + client_group: text("client_group").notNull(), 76 + // You can use { mode: "bigint" } if numbers are exceeding js number limitations 77 + last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), 25 78 }); 26 79 27 80 export const facts = pgTable("facts", { 28 - id: uuid("id").defaultRandom().primaryKey().notNull(), 29 - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), 30 - attribute: text("attribute").notNull(), 31 - data: jsonb("data").notNull(), 32 - created_at: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), 33 - updated_at: timestamp("updated_at", { mode: 'string' }), 34 - // You can use { mode: "bigint" } if numbers are exceeding js number limitations 35 - version: bigint("version", { mode: "number" }).default(0).notNull(), 36 - }); 81 + id: uuid("id").defaultRandom().primaryKey().notNull(), 82 + entity: uuid("entity") 83 + .notNull() 84 + .references(() => entities.id, { 85 + onDelete: "cascade", 86 + onUpdate: "restrict", 87 + }), 88 + attribute: text("attribute").notNull().$type<keyof typeof Attributes>(), 89 + data: jsonb("data").notNull().$type<Fact<any>["data"]>(), 90 + created_at: timestamp("created_at", { mode: "string" }) 91 + .defaultNow() 92 + .notNull(), 93 + updated_at: timestamp("updated_at", { mode: "string" }), 94 + // You can use { mode: "bigint" } if numbers are exceeding js number limitations 95 + version: bigint("version", { mode: "number" }).default(0).notNull(), 96 + });
+35
package-lock.json
··· 15 15 "@vercel/kv": "^1.0.1", 16 16 "base64-js": "^1.5.1", 17 17 "drizzle-orm": "^0.30.10", 18 + "fractional-indexing": "^3.2.0", 18 19 "next": "^14.2.3", 19 20 "postgres": "^3.4.4", 21 + "prosemirror-commands": "^1.5.2", 22 + "prosemirror-keymap": "^1.2.2", 20 23 "prosemirror-schema-basic": "^1.2.2", 21 24 "prosemirror-state": "^1.4.3", 22 25 "react": "^18.3.1", ··· 4872 4875 "url": "https://github.com/sponsors/rawify" 4873 4876 } 4874 4877 }, 4878 + "node_modules/fractional-indexing": { 4879 + "version": "3.2.0", 4880 + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", 4881 + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", 4882 + "engines": { 4883 + "node": "^14.13.1 || >=16.0.0" 4884 + } 4885 + }, 4875 4886 "node_modules/fs.realpath": { 4876 4887 "version": "1.0.0", 4877 4888 "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", ··· 6813 6824 "react-is": "^16.13.1" 6814 6825 } 6815 6826 }, 6827 + "node_modules/prosemirror-commands": { 6828 + "version": "1.5.2", 6829 + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.5.2.tgz", 6830 + "integrity": "sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ==", 6831 + "dependencies": { 6832 + "prosemirror-model": "^1.0.0", 6833 + "prosemirror-state": "^1.0.0", 6834 + "prosemirror-transform": "^1.0.0" 6835 + } 6836 + }, 6837 + "node_modules/prosemirror-keymap": { 6838 + "version": "1.2.2", 6839 + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz", 6840 + "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==", 6841 + "dependencies": { 6842 + "prosemirror-state": "^1.0.0", 6843 + "w3c-keyname": "^2.2.0" 6844 + } 6845 + }, 6816 6846 "node_modules/prosemirror-model": { 6817 6847 "version": "1.21.0", 6818 6848 "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.21.0.tgz", ··· 8114 8144 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 8115 8145 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 8116 8146 "dev": true 8147 + }, 8148 + "node_modules/w3c-keyname": { 8149 + "version": "2.2.8", 8150 + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", 8151 + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" 8117 8152 }, 8118 8153 "node_modules/web-streams-polyfill": { 8119 8154 "version": "3.3.3",
+3
package.json
··· 21 21 "@vercel/kv": "^1.0.1", 22 22 "base64-js": "^1.5.1", 23 23 "drizzle-orm": "^0.30.10", 24 + "fractional-indexing": "^3.2.0", 24 25 "next": "^14.2.3", 25 26 "postgres": "^3.4.4", 27 + "prosemirror-commands": "^1.5.2", 28 + "prosemirror-keymap": "^1.2.2", 26 29 "prosemirror-schema-basic": "^1.2.2", 27 30 "prosemirror-state": "^1.4.3", 28 31 "react": "^18.3.1",
+1 -1
replicache/attributes.ts
··· 1 1 export const Attributes = { 2 2 "card/block": { 3 - type: "reference", 3 + type: "ordered-reference", 4 4 cardinality: "many", 5 5 }, 6 6 "block/position": {
+53
replicache/clientMutationContext.ts
··· 1 + import { WriteTransaction } from "replicache"; 2 + import * as Y from "yjs"; 3 + import * as base64 from "base64-js"; 4 + import { FactWithIndexes } from "./utils"; 5 + import { Attributes } from "./attributes"; 6 + import { Fact } from "."; 7 + import { MutationContext } from "./mutations"; 8 + 9 + export function clientMutationContext(tx: WriteTransaction) { 10 + let ctx: MutationContext = { 11 + async createEntity(_entityID) { 12 + return true; 13 + }, 14 + scanIndex: { 15 + async eav(entity, attribute) { 16 + let existingFact = await tx 17 + .scan<Fact<typeof attribute>>({ 18 + indexName: "eav", 19 + prefix: `${entity}-${attribute}`, 20 + }) 21 + .toArray(); 22 + return existingFact; 23 + }, 24 + }, 25 + async assertFact(f) { 26 + let attribute = Attributes[f.attribute as keyof typeof Attributes]; 27 + if (!attribute) return; 28 + let id = f.id || crypto.randomUUID(); 29 + let data = { ...f.data }; 30 + if (attribute.cardinality === "one") { 31 + let existingFact = await tx 32 + .scan<Fact<typeof f.attribute>>({ 33 + indexName: "eav", 34 + prefix: `${f.entity}-${f.attribute}`, 35 + }) 36 + .toArray(); 37 + if (existingFact[0]) { 38 + id = existingFact[0].id; 39 + if (attribute.type === "text") { 40 + const oldUpdate = base64.toByteArray( 41 + (existingFact[0]?.data as Fact<typeof f.attribute>["data"]).value, 42 + ); 43 + const newUpdate = base64.toByteArray(f.data.value); 44 + const updateBytes = Y.mergeUpdatesV2([oldUpdate, newUpdate]); 45 + data.value = base64.fromByteArray(updateBytes); 46 + } 47 + } 48 + } 49 + await tx.set(id, FactWithIndexes({ id, ...f, data })); 50 + }, 51 + }; 52 + return ctx; 53 + }
+25 -52
replicache/index.tsx
··· 1 1 "use client"; 2 - import * as base64 from "base64-js"; 3 - import * as Y from "yjs"; 4 2 import { createContext, useContext, useEffect, useState } from "react"; 5 3 import { DeepReadonlyObject, Replicache, WriteTransaction } from "replicache"; 6 4 import { Pull } from "./pull"; 7 - import { MutationContext, mutations } from "./mutations"; 5 + import { mutations } from "./mutations"; 8 6 import { Attributes } from "./attributes"; 9 7 import { Push } from "./push"; 10 - import { FactWithIndexes } from "./utils"; 11 8 import { createClient } from "@supabase/supabase-js"; 12 9 import { Database } from "../supabase/database.types"; 10 + import { clientMutationContext } from "./clientMutationContext"; 13 11 14 12 export type Fact<A extends keyof typeof Attributes> = { 15 13 id: string; ··· 20 18 21 19 type Data<A extends keyof typeof Attributes> = { 22 20 text: { type: "text"; value: string }; 21 + "ordered-reference": { 22 + type: "ordered-reference"; 23 + position: string; 24 + value: string; 25 + }; 23 26 reference: { type: "reference"; value: string }; 24 27 }[(typeof Attributes)[A]["type"]]; 25 28 ··· 29 32 export function useReplicache() { 30 33 return useContext(ReplicacheContext); 31 34 } 32 - type ReplicacheMutators = { 35 + export type ReplicacheMutators = { 33 36 [k in keyof typeof mutations]: ( 34 37 tx: WriteTransaction, 35 38 args: Parameters<(typeof mutations)[k]>[0], ··· 51 54 return [ 52 55 m, 53 56 async (tx: WriteTransaction, args: any) => { 54 - await mutations[m as keyof typeof mutations](args, { 55 - async createEntity(_entityID) { 56 - return true; 57 - }, 58 - async assertFact(f) { 59 - let attribute = 60 - Attributes[f.attribute as keyof typeof Attributes]; 61 - if (!attribute) return; 62 - let id = f.id || crypto.randomUUID(); 63 - let data = { ...f.data }; 64 - if (attribute.cardinality === "one") { 65 - let existingFact = await tx 66 - .scan<Fact<typeof f.attribute>>({ 67 - indexName: "eav", 68 - prefix: `${f.entity}-${f.attribute}`, 69 - }) 70 - .toArray(); 71 - if (existingFact[0]) { 72 - id = existingFact[0].id; 73 - if (attribute.type === "text") { 74 - const oldUpdate = base64.toByteArray( 75 - ( 76 - existingFact[0]?.data as Fact< 77 - typeof f.attribute 78 - >["data"] 79 - ).value, 80 - ); 81 - const newUpdate = base64.toByteArray(f.data.value); 82 - const updateBytes = Y.mergeUpdatesV2([ 83 - oldUpdate, 84 - newUpdate, 85 - ]); 86 - data.value = base64.fromByteArray(updateBytes); 87 - } 88 - } 89 - } 90 - await tx.set(id, FactWithIndexes({ id, ...f, data })); 91 - }, 92 - } as MutationContext); 57 + await mutations[m as keyof typeof mutations]( 58 + args, 59 + clientMutationContext(tx), 60 + ); 93 61 }, 94 62 ]; 95 63 }), ··· 134 102 ); 135 103 } 136 104 137 - export function useEntity(entity: string, attribute: keyof typeof Attributes) { 138 - let [data, setData] = useState< 139 - null | DeepReadonlyObject<Fact<typeof attribute>>[] 140 - >(null); 105 + type CardinalityResult<A extends keyof typeof Attributes> = 106 + (typeof Attributes)[A]["cardinality"] extends "one" 107 + ? DeepReadonlyObject<Fact<A>> 108 + : DeepReadonlyObject<Fact<A>>[]; 109 + export function useEntity<A extends keyof typeof Attributes>( 110 + entity: string, 111 + attribute: A, 112 + ): CardinalityResult<A> { 113 + let [data, setData] = useState<DeepReadonlyObject<Fact<A>[]>>([]); 141 114 let { rep } = useReplicache(); 142 115 useEffect(() => { 143 116 if (!rep) return; 144 117 return rep.subscribe( 145 118 (tx) => { 146 119 return tx 147 - .scan< 148 - Fact<typeof attribute> 149 - >({ indexName: "eav", prefix: `${entity}-${attribute}` }) 120 + .scan<Fact<A>>({ indexName: "eav", prefix: `${entity}-${attribute}` }) 150 121 .toArray(); 151 122 }, 152 123 { onData: setData }, 153 124 ); 154 125 }, [entity, attribute, rep]); 155 - return data; 126 + return Attributes[attribute].cardinality === "many" 127 + ? (data as CardinalityResult<A>) 128 + : (data[0] as CardinalityResult<A>); 156 129 }
+17 -6
replicache/mutations.ts
··· 1 + import { DeepReadonly } from "replicache"; 1 2 import { Fact } from "."; 2 3 import { Attributes } from "./attributes"; 3 4 4 5 export type MutationContext = { 5 6 createEntity: (entityID: string) => Promise<boolean>; 7 + scanIndex: { 8 + eav: <A extends keyof typeof Attributes>( 9 + entity: string, 10 + attribute: A, 11 + ) => Promise<DeepReadonly<Fact<A>[]>>; 12 + }; 6 13 assertFact: <A extends keyof typeof Attributes>( 7 14 f: Omit<Fact<A>, "id"> & { id?: string }, 8 15 ) => Promise<void>; ··· 10 17 11 18 type Mutation<T> = (args: T, ctx: MutationContext) => Promise<void>; 12 19 13 - const addBlock: Mutation<{ parent: string; newEntityID: string }> = async ( 14 - args, 15 - ctx, 16 - ) => { 17 - console.log(args.parent); 20 + const addBlock: Mutation<{ 21 + parent: string; 22 + newEntityID: string; 23 + position: string; 24 + }> = async (args, ctx) => { 18 25 await ctx.createEntity(args.newEntityID); 19 26 await ctx.assertFact({ 20 27 entity: args.parent, 21 - data: { type: "reference", value: args.newEntityID }, 28 + data: { 29 + type: "ordered-reference", 30 + value: args.newEntityID, 31 + position: args.position, 32 + }, 22 33 attribute: "card/block", 23 34 }); 24 35 };
+3 -17
replicache/pull.ts
··· 10 10 import { Fact } from "."; 11 11 import postgres from "postgres"; 12 12 import { drizzle } from "drizzle-orm/postgres-js"; 13 - import { getClientGroup } from "./utils"; 13 + import { FactWithIndexes, getClientGroup } from "./utils"; 14 + import { Attributes } from "./attributes"; 14 15 let supabase = createClient<Database>( 15 16 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 16 17 process.env.SUPABASE_SERVICE_ROLE_KEY as string, ··· 29 30 30 31 return { 31 32 cookie: Date.now(), 32 - //TODO When we implement push 33 33 lastMutationIDChanges: clientGroup, 34 34 patch: [ 35 35 { op: "clear" }, ··· 37 37 return { 38 38 op: "put", 39 39 key: f.id, 40 - value: FactWithIndexes(f as unknown as Fact), 40 + value: FactWithIndexes(f as unknown as Fact<keyof typeof Attributes>), 41 41 } as const; 42 42 }), 43 43 ], ··· 48 48 error: "VersionNotSupported", 49 49 versionType: "pull", 50 50 }; 51 - 52 - function FactWithIndexes(f: Fact) { 53 - let indexes: { 54 - eav: string; 55 - aev: string; 56 - vae?: string; 57 - } = { 58 - eav: `${f.entity}-${f.attribute}-${f.id}`, 59 - aev: `${f.attribute}-${f.entity}-${f.id}`, 60 - }; 61 - if (f.data.type === "reference") 62 - indexes.vae = `${f.data.value}-${f.attribute}`; 63 - return { ...f, indexes }; 64 - }
+3 -64
replicache/push.ts
··· 1 1 "use server"; 2 2 import { PushRequest, PushResponse } from "replicache"; 3 - import * as base64 from "base64-js"; 4 - import * as Y from "yjs"; 3 + import { serverMutationContext } from "./serverMutationContext"; 5 4 import { mutations } from "./mutations"; 6 5 import { drizzle } from "drizzle-orm/postgres-js"; 7 - import * as driz from "drizzle-orm"; 8 6 import postgres from "postgres"; 9 - import { entities, facts, replicache_clients } from "../drizzle/schema"; 10 - import { Attributes } from "./attributes"; 7 + import { replicache_clients } from "../drizzle/schema"; 11 8 import { getClientGroup } from "./utils"; 12 9 import { createClient } from "@supabase/supabase-js"; 13 10 import { Database } from "../supabase/database.types"; 14 - import { Fact } from "."; 15 11 16 12 const client = postgres(process.env.DB_URL as string); 17 13 let supabase = createClient<Database>( ··· 36 32 } 37 33 db.transaction(async (tx) => { 38 34 try { 39 - await mutations[name](mutation.args as any, { 40 - async createEntity(entity) { 41 - console.log( 42 - await tx.insert(entities).values({ 43 - id: entity, 44 - }), 45 - ); 46 - return true; 47 - }, 48 - async assertFact(f) { 49 - let attribute = Attributes[f.attribute as keyof typeof Attributes]; 50 - if (!attribute) return; 51 - let id = f.id || crypto.randomUUID(); 52 - let data = { ...f.data }; 53 - if (attribute.cardinality === "one") { 54 - let existingFact = await tx 55 - .select({ id: facts.id, data: facts.data }) 56 - .from(facts) 57 - .where( 58 - driz.and( 59 - driz.eq(facts.attribute, f.attribute), 60 - driz.eq(facts.entity, f.entity), 61 - ), 62 - ); 63 - if (existingFact[0]) { 64 - id = existingFact[0].id; 65 - if (attribute.type === "text") { 66 - const oldUpdate = base64.toByteArray( 67 - (existingFact[0]?.data as Fact<typeof f.attribute>["data"]) 68 - .value, 69 - ); 70 - console.log("mergin updates"); 71 - const newUpdate = base64.toByteArray(f.data.value); 72 - const updateBytes = Y.mergeUpdatesV2([oldUpdate, newUpdate]); 73 - data.value = base64.fromByteArray(updateBytes); 74 - } 75 - } 76 - } 77 - await tx.transaction( 78 - async (tx2) => 79 - await tx2 80 - .insert(facts) 81 - .values({ 82 - id: id, 83 - entity: f.entity, 84 - data: driz.sql`${data}::jsonb`, 85 - attribute: f.attribute, 86 - }) 87 - .onConflictDoUpdate({ 88 - target: facts.id, 89 - set: { data: driz.sql`${f.data}::jsonb` }, 90 - }) 91 - .catch((e) => { 92 - console.log(`error on inserting fact: `, JSON.stringify(e)); 93 - }), 94 - ); 95 - }, 96 - }); 35 + await mutations[name](mutation.args as any, serverMutationContext(tx)); 97 36 } catch (e) { 98 37 console.log( 99 38 `Error occured while running mutation: ${name}`,
+87
replicache/serverMutationContext.ts
··· 1 + import { PgTransaction } from "drizzle-orm/pg-core"; 2 + import * as driz from "drizzle-orm"; 3 + import * as base64 from "base64-js"; 4 + import * as Y from "yjs"; 5 + import { MutationContext } from "./mutations"; 6 + import { entities, facts } from "../drizzle/schema"; 7 + import { Attributes } from "./attributes"; 8 + import { Fact } from "."; 9 + import { DeepReadonly } from "replicache"; 10 + export function serverMutationContext(tx: PgTransaction<any, any, any>) { 11 + let ctx: MutationContext = { 12 + async createEntity(entity) { 13 + console.log( 14 + await tx.insert(entities).values({ 15 + id: entity, 16 + }), 17 + ); 18 + return true; 19 + }, 20 + scanIndex: { 21 + async eav(entity, attribute) { 22 + return (await tx 23 + .select({ 24 + id: facts.id, 25 + data: facts.data, 26 + entity: facts.entity, 27 + attribute: facts.attribute, 28 + }) 29 + .from(facts) 30 + .where( 31 + driz.and( 32 + driz.eq(facts.attribute, attribute), 33 + driz.eq(facts.entity, entity), 34 + ), 35 + )) as DeepReadonly<Fact<typeof attribute>>[]; 36 + }, 37 + }, 38 + async assertFact(f) { 39 + let attribute = Attributes[f.attribute as keyof typeof Attributes]; 40 + if (!attribute) return; 41 + let id = f.id || crypto.randomUUID(); 42 + let data = { ...f.data }; 43 + if (attribute.cardinality === "one") { 44 + let existingFact = await tx 45 + .select({ id: facts.id, data: facts.data }) 46 + .from(facts) 47 + .where( 48 + driz.and( 49 + driz.eq(facts.attribute, f.attribute), 50 + driz.eq(facts.entity, f.entity), 51 + ), 52 + ); 53 + if (existingFact[0]) { 54 + id = existingFact[0].id; 55 + if (attribute.type === "text") { 56 + const oldUpdate = base64.toByteArray( 57 + (existingFact[0]?.data as Fact<typeof f.attribute>["data"]).value, 58 + ); 59 + console.log("mergin updates"); 60 + const newUpdate = base64.toByteArray(f.data.value); 61 + const updateBytes = Y.mergeUpdatesV2([oldUpdate, newUpdate]); 62 + data.value = base64.fromByteArray(updateBytes); 63 + } 64 + } 65 + } 66 + await tx.transaction( 67 + async (tx2) => 68 + await tx2 69 + .insert(facts) 70 + .values({ 71 + id: id, 72 + entity: f.entity, 73 + data: driz.sql`${data}::jsonb`, 74 + attribute: f.attribute, 75 + }) 76 + .onConflictDoUpdate({ 77 + target: facts.id, 78 + set: { data: driz.sql`${f.data}::jsonb` }, 79 + }) 80 + .catch((e) => { 81 + console.log(`error on inserting fact: `, JSON.stringify(e)); 82 + }), 83 + ); 84 + }, 85 + }; 86 + return ctx; 87 + }
+3 -2
replicache/utils.ts
··· 2 2 import * as driz from "drizzle-orm"; 3 3 import { Fact } from "."; 4 4 import { replicache_clients } from "../drizzle/schema"; 5 + import { Attributes } from "./attributes"; 5 6 6 - export function FactWithIndexes(f: Fact) { 7 + export function FactWithIndexes(f: Fact<keyof typeof Attributes>) { 7 8 let indexes: { 8 9 eav: string; 9 10 aev: string; ··· 12 13 eav: `${f.entity}-${f.attribute}-${f.id}`, 13 14 aev: `${f.attribute}-${f.entity}-${f.id}`, 14 15 }; 15 - if (f.data.type === "reference") 16 + if (f.data.type === "reference" || f.data.type === "ordered-reference") 16 17 indexes.vae = `${f.data.value}-${f.attribute}`; 17 18 return { ...f, indexes }; 18 19 }
+4 -6
supabase/migrations/20240519231512_init.sql
··· 32 32 33 33 CREATE UNIQUE INDEX entities_pkey ON public.entities USING btree (id); 34 34 35 - CREATE INDEX facts_expr_idx ON public.facts USING btree (((data ->> 'value'::text))) WHERE ((data ->> 'type'::text) = 'reference'::text); 35 + CREATE INDEX facts_reference_idx ON public.facts USING btree (((data ->> 'value'::text))) WHERE (((data ->> 'type'::text) = 'reference'::text) OR ((data ->> 'type'::text) = 'ordered-reference'::text)); 36 36 37 37 CREATE UNIQUE INDEX facts_pkey ON public.facts USING btree (id); 38 38 ··· 51 51 CREATE OR REPLACE FUNCTION public.get_facts(root uuid) 52 52 RETURNS SETOF facts 53 53 LANGUAGE sql 54 - AS $function$ 55 - WITH RECURSIVE all_facts as ( 54 + AS $function$WITH RECURSIVE all_facts as ( 56 55 select 57 56 * 58 57 from ··· 66 65 facts f 67 66 inner join all_facts f1 on ( 68 67 uuid(f1.data ->> 'value') = f.entity 69 - ) where f1.data ->> 'type' = 'reference' 68 + ) where f1.data ->> 'type' = 'reference' or f1.data ->> 'type' = 'ordered-reference' 70 69 ) 71 70 select 72 71 * 73 72 from 74 - all_facts; 75 - $function$ 73 + all_facts;$function$ 76 74 ; 77 75 78 76 grant delete on table "public"."entities" to "anon";
+5
utils/elementId.ts
··· 1 + export const elementId = { 2 + block: (id: string) => ({ 3 + text: `block/${id}/content`, 4 + }), 5 + };