diff --git a/backend/data.ts b/backend/data.ts index 74bc786..21ca739 100644 --- a/backend/data.ts +++ b/backend/data.ts @@ -1,14 +1,5 @@ -/** - * Abstract RDS (SQL) to entities (Shape, Client). - */ - -import { shape } from "../shared/shape"; -import { clientState } from "../shared/client-state"; -import { must } from "./decode"; - import type { ExecuteStatementFn } from "./rds"; -import type { Shape } from "../shared/shape"; -import type { ClientState } from "../shared/client-state"; +import { JSONValue } from "replicache"; export async function getCookieVersion( executor: ExecuteStatementFn @@ -49,60 +40,27 @@ export async function setLastMutationID( ); } -export async function getShape( - executor: ExecuteStatementFn, - id: string -): Promise { - const { records } = await executor( - "SELECT Content FROM Shape WHERE Id = :id", - { - id: { stringValue: id }, - } - ); - const content = records?.[0]?.[0]?.stringValue; - if (!content) { - return null; - } - return must(shape.decode(JSON.parse(content))); -} - -export async function putShape( +export async function getObject( executor: ExecuteStatementFn, - id: string, - shape: Shape -): Promise { - await executor(`CALL PutShape(:id, :content)`, { - id: { stringValue: id }, - content: { stringValue: JSON.stringify(shape) }, + key: string +): Promise { + const { records } = await executor("SELECT V FROM Object WHERE K = :key", { + key: { stringValue: key }, }); -} - -export async function getClientState( - executor: ExecuteStatementFn, - id: string -): Promise { - const { records } = await executor( - "SELECT Content FROM ClientState WHERE Id = :id", - { - id: { stringValue: id }, - } - ); - const content = records?.[0]?.[0]?.stringValue; - if (!content) { - return { - overID: "", - }; + const value = records?.[0]?.[0]?.stringValue; + if (!value) { + return null; } - return must(clientState.decode(JSON.parse(content))); + return JSON.parse(value); } -export async function putClientState( +export async function putObject( executor: ExecuteStatementFn, - id: string, - clientState: ClientState + key: string, + value: JSONValue ): Promise { - await executor(`CALL PutClientState(:id, :content)`, { - id: { stringValue: id }, - content: { stringValue: JSON.stringify(clientState) }, + await executor(`CALL PutObject(:key, :value)`, { + key: { stringValue: key }, + value: { stringValue: JSON.stringify(value) }, }); } diff --git a/backend/rds.ts b/backend/rds.ts index ec2f6db..1989cb4 100644 --- a/backend/rds.ts +++ b/backend/rds.ts @@ -9,7 +9,6 @@ import { RDSDataClient, RollbackTransactionCommand, } from "@aws-sdk/client-rds-data"; -import { execute } from "fp-ts/lib/State"; const region = "us-west-2"; const dbName = @@ -100,16 +99,9 @@ async function createDatabase() { LastMutationID BIGINT NOT NULL, LastModified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP)`); - await executeStatement(`CREATE TABLE ClientState ( - Id VARCHAR(255) PRIMARY KEY NOT NULL, - Content TEXT NOT NULL, - Version BIGINT NOT NULL)`); - // TODO: When https://github.com/rocicorp/replicache-sdk-js/issues/275 is - // fixed, can enable this. - //FOREIGN KEY (Id) REFERENCES Client (Id), - await executeStatement(`CREATE TABLE Shape ( - Id VARCHAR(255) PRIMARY KEY NOT NULL, - Content TEXT NOT NULL, + await executeStatement(`CREATE TABLE Object ( + K VARCHAR(255) PRIMARY KEY NOT NULL, + V TEXT NOT NULL, Version BIGINT NOT NULL)`); await executeStatement(`INSERT INTO Cookie (Version) VALUES (0)`); @@ -127,20 +119,12 @@ async function createDatabase() { SELECT Version INTO result FROM Cookie; END`); - await executeStatement(`CREATE PROCEDURE PutShape (IN pId VARCHAR(255), IN pContent TEXT) - BEGIN - SET @version = 0; - CALL NextVersion(@version); - INSERT INTO Shape (Id, Content, Version) VALUES (pId, pContent, @version) - ON DUPLICATE KEY UPDATE Id = pId, Content = pContent, Version = @version; - END`); - - await executeStatement(`CREATE PROCEDURE PutClientState (IN pId VARCHAR(255), IN pContent TEXT) + await executeStatement(`CREATE PROCEDURE PutObject (IN pK VARCHAR(255), IN pV TEXT) BEGIN SET @version = 0; CALL NextVersion(@version); - INSERT INTO ClientState (Id, Content, Version) VALUES (pId, pContent, @version) - ON DUPLICATE KEY UPDATE Id = pId, Content = pContent, Version = @version; + INSERT INTO Object (K, V, Version) VALUES (pK, pV, @version) + ON DUPLICATE KEY UPDATE V = pV, Version = @version; END`); } diff --git a/frontend/data.ts b/frontend/data.ts index 749b076..e27697f 100644 --- a/frontend/data.ts +++ b/frontend/data.ts @@ -1,11 +1,7 @@ -import Replicache, { - JSONObject, - ReadTransaction, - WriteTransaction, -} from "replicache"; +import Replicache, { ReadTransaction, WriteTransaction } from "replicache"; import { useSubscribe } from "replicache-react-util"; -import { Shape } from "../shared/shape"; -import { ClientState } from "../shared/client-state"; +import { getShape, Shape } from "../shared/shape"; +import { getClientState } from "../shared/client-state"; import { createShape, CreateShapeArgs, @@ -14,9 +10,8 @@ import { overShape, OverShapeArgs, } from "../shared/mutators"; -import type { MutatorStorage } from "../shared/mutators"; +import type Storage from "../shared/storage"; import { newID } from "../shared/id"; -import { Type } from "io-ts"; /** * Abstracts Replicache storage (key/value pairs) to entities (Shape). @@ -38,21 +33,21 @@ export class Data { this.createShape = rep.register( "createShape", async (tx: WriteTransaction, args: CreateShapeArgs) => { - await createShape(this.mutatorStorage(tx), args); + await createShape(this.writeStorage(tx), args); } ); this.moveShape = rep.register( "moveShape", async (tx: WriteTransaction, args: MoveShapeArgs) => { - await moveShape(this.mutatorStorage(tx), args); + await moveShape(this.writeStorage(tx), args); } ); this.overShape = rep.register( "overShape", async (tx: WriteTransaction, args: OverShapeArgs) => { - await overShape(this.mutatorStorage(tx), args); + await overShape(this.writeStorage(tx), args); } ); } @@ -77,7 +72,7 @@ export class Data { return useSubscribe( this.rep, (tx: ReadTransaction) => { - return this.getShape(tx, id); + return getShape(this.readStorage(tx), id); }, null ); @@ -85,54 +80,22 @@ export class Data { useOverShapeID(): string | null { return useSubscribe(this.rep, async (tx: ReadTransaction) => { - return (await this.getClientState(tx, this.clientID)).overID; + return (await getClientState(this.readStorage(tx), this.clientID)).overID; }); } - private async getShape( - tx: ReadTransaction, - id: string - ): Promise { - // TODO: validate returned shape - can be wrong in case app reboots with - // new code and old storage. We can decode, but then what? - // See https://github.com/rocicorp/replicache-sdk-js/issues/285. - return ((await tx.get(`shape-${id}`)) as unknown) as Shape | null; - } - - private async putShape(tx: WriteTransaction, id: string, shape: Shape) { - return await tx.put(`shape-${id}`, (shape as unknown) as JSONObject); - } - - private async getClientState( - tx: ReadTransaction, - id: string - ): Promise { - return ( - (((await tx.get( - `client-state-${id}` - )) as unknown) as ClientState | null) || { - overID: "", - } - ); - } - - private async putClientState( - tx: WriteTransaction, - id: string, - client: ClientState - ) { - return await tx.put( - `client-state-${id}`, - (client as unknown) as JSONObject - ); - } - - private mutatorStorage(tx: WriteTransaction): MutatorStorage { + private readStorage(tx: ReadTransaction): Storage { return { - getShape: this.getShape.bind(null, tx), - putShape: this.putShape.bind(null, tx), - getClientState: this.getClientState.bind(null, tx), - putClientState: this.putClientState.bind(null, tx), + getObject: tx.get.bind(tx), + putObject: () => { + throw new Error("Cannot write inside ReadTransaction"); + }, }; } + + private writeStorage(tx: WriteTransaction): Storage { + return Object.assign(this.readStorage(tx), { + putObject: tx.put.bind(tx), + }); + } } diff --git a/frontend/designer.tsx b/frontend/designer.tsx index 2714247..4bc4876 100644 --- a/frontend/designer.tsx +++ b/frontend/designer.tsx @@ -89,15 +89,16 @@ export function Designer({ data }: { data: Data }) { // paint the highlighted object again in a special 'highlight' // mode. overID && ( - - )} + + ) + } diff --git a/pages/api/replicache-pull.ts b/pages/api/replicache-pull.ts index fbaf250..ab06ae1 100644 --- a/pages/api/replicache-pull.ts +++ b/pages/api/replicache-pull.ts @@ -11,16 +11,13 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { const pull = must(pullRequest.decode(req.body)); let cookie = pull.baseStateID == "" ? 0 : parseInt(pull.baseStateID); - console.time(`Reading all Shapes...`); - let shapes, clientStates; + console.time(`Reading all objects...`); + let entries; let lastMutationID = 0; await transact(async (executor) => { - [shapes, clientStates, lastMutationID, cookie] = await Promise.all([ - executor("SELECT * FROM Shape WHERE Version > :version", { - version: { longValue: cookie }, - }), - executor("SELECT * FROM ClientState WHERE Version > :version", { + [entries, lastMutationID, cookie] = await Promise.all([ + executor("SELECT * FROM Object WHERE Version > :version", { version: { longValue: cookie }, }), getLastMutationID(executor, pull.clientID), @@ -28,12 +25,11 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { ]); }); console.log({ lastMutationID }); - console.timeEnd(`Reading all Shapes...`); + console.timeEnd(`Reading all objects...`); // Grump. Typescript seems to not understand that the argument to transact() // is guaranteed to have been called before transact() exits. - shapes = (shapes as any) as ExecuteStatementCommandOutput; - clientStates = (clientStates as any) as ExecuteStatementCommandOutput; + entries = (entries as any) as ExecuteStatementCommandOutput; const resp: PullResponse = { lastMutationID, @@ -46,31 +42,28 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { }, }; - for (let entries of [shapes, clientStates]) { - if (entries.records) { - for (let row of entries.records) { - const [ - { stringValue: id }, - { stringValue: content }, - { booleanValue: deleted }, - ] = row as [ - Field.StringValueMember, - Field.StringValueMember, - Field.BooleanValueMember - ]; - const prefix = entries == shapes ? "shape" : "client-state"; - if (deleted) { - resp.patch.push({ - op: "remove", - path: `/${prefix}-${id}`, - }); - } else { - resp.patch.push({ - op: "replace", - path: `/${prefix}-${id}`, - valueString: content, - }); - } + if (entries.records) { + for (let row of entries.records) { + const [ + { stringValue: key }, + { stringValue: content }, + { booleanValue: deleted }, + ] = row as [ + Field.StringValueMember, + Field.StringValueMember, + Field.BooleanValueMember + ]; + if (deleted) { + resp.patch.push({ + op: "remove", + path: `/${key}`, + }); + } else { + resp.patch.push({ + op: "replace", + path: `/${key}`, + valueString: content, + }); } } } diff --git a/pages/api/replicache-push.ts b/pages/api/replicache-push.ts index 3fa4303..b35499f 100644 --- a/pages/api/replicache-push.ts +++ b/pages/api/replicache-push.ts @@ -1,7 +1,6 @@ import * as t from "io-ts"; import { ExecuteStatementFn, transact } from "../../backend/rds"; import { - MutatorStorage, createShape, createShapeArgs, moveShape, @@ -11,13 +10,12 @@ import { overShapeArgs, } from "../../shared/mutators"; import { - getClientState, + getObject, + putObject, getLastMutationID, - getShape, - putClientState, - putShape, setLastMutationID, } from "../../backend/data"; +import type Storage from "../../shared/storage"; import { must } from "../../backend/decode"; import Pusher from "pusher"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -49,16 +47,17 @@ type Mutation = t.TypeOf; export default async (req: NextApiRequest, res: NextApiResponse) => { const push = must(pushRequest.decode(req.body)); + console.log("Processing push", push); for (let i = 0; i < push.mutations.length; i++) { await transact(async (executor) => { - console.log("Processing mutation", mutation); - let lastMutationID = await getLastMutationID(executor, push.clientID); - console.log("lastMutationID:", lastMutationID); + console.log({ lastMutationID }); // Scan forward from here collapsing any collapsable mutations. for (let mutation: Mutation; (mutation = push.mutations[i]); i++) { + console.log({ mutation }); + const expectedMutationID = lastMutationID + 1; if (mutation.id < expectedMutationID) { console.log( @@ -83,17 +82,17 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { } } - const ms = mutatorStorage(executor); + const s = storage(executor); switch (mutation.name) { case "moveShape": - await moveShape(ms, mutation.args); + await moveShape(s, mutation.args); break; case "createShape": - await createShape(ms, must(createShapeArgs.decode(mutation.args))); + await createShape(s, mutation.args); break; case "overShape": - await overShape(ms, must(overShapeArgs.decode(mutation.args))); + await overShape(s, mutation.args); break; } @@ -128,11 +127,9 @@ function collapse(prev: Mutation, next: Mutation): boolean { return false; } -function mutatorStorage(executor: ExecuteStatementFn): MutatorStorage { +function storage(executor: ExecuteStatementFn): Storage { return { - getShape: getShape.bind(null, executor), - putShape: putShape.bind(null, executor), - getClientState: getClientState.bind(null, executor), - putClientState: putClientState.bind(null, executor), + getObject: getObject.bind(null, executor), + putObject: putObject.bind(null, executor), }; } diff --git a/shared/client-state.ts b/shared/client-state.ts index eaa5ff0..234d3a9 100644 --- a/shared/client-state.ts +++ b/shared/client-state.ts @@ -1,7 +1,31 @@ import * as t from "io-ts"; +import { must } from "../backend/decode"; +import Storage from "./storage"; +// TODO: It would be good to merge this with the first-class concept of `client` +// that Replicache itself manages if possible. export const clientState = t.type({ overID: t.string, }); export type ClientState = t.TypeOf; + +export async function getClientState( + storage: Storage, + id: string +): Promise { + const jv = await storage.getObject(key(id)); + return jv ? must(clientState.decode(jv)) : { overID: "" }; +} + +export function putClientState( + storage: Storage, + id: string, + clientState: ClientState +): Promise { + return storage.putObject(key(id), clientState); +} + +function key(id: string): string { + return `client-state-${id}`; +} diff --git a/shared/mutators.ts b/shared/mutators.ts index 4a16028..eb17555 100644 --- a/shared/mutators.ts +++ b/shared/mutators.ts @@ -7,26 +7,15 @@ */ import * as t from "io-ts"; -import { shape } from "./shape"; -import type { Shape } from "./shape"; -import { clientState } from "./client-state"; -import type { ClientState } from "./client-state"; - -/** - * Interface required of underlying storage. - */ -export interface MutatorStorage { - getShape(id: string): Promise; - putShape(id: string, shape: Shape): Promise; - getClientState(id: string): Promise; - putClientState(id: string, client: ClientState): Promise; -} +import { shape, getShape, putShape } from "./shape"; +import { getClientState, putClientState } from "./client-state"; +import Storage from "./storage"; export async function createShape( - storage: MutatorStorage, + storage: Storage, args: CreateShapeArgs ): Promise { - await storage.putShape(args.id, args.shape); + await putShape(storage, args.id, args.shape); } // TODO: Is there a way to make this a little less laborious? export const createShapeArgs = t.type({ @@ -36,17 +25,17 @@ export const createShapeArgs = t.type({ export type CreateShapeArgs = t.TypeOf; export async function moveShape( - storage: MutatorStorage, + storage: Storage, args: MoveShapeArgs ): Promise { - const shape = await storage.getShape(args.id); + const shape = await getShape(storage, args.id); if (!shape) { console.log(`Specified shape ${args.id} not found.`); return; } shape.x += args.dx; shape.y += args.dy; - await storage.putShape(args.id, shape); + await putShape(storage, args.id, shape); } export const moveShapeArgs = t.type({ id: t.string, @@ -56,12 +45,12 @@ export const moveShapeArgs = t.type({ export type MoveShapeArgs = t.TypeOf; export async function overShape( - storage: MutatorStorage, + storage: Storage, args: OverShapeArgs ): Promise { - const client = await storage.getClientState(args.clientID); + const client = await getClientState(storage, args.clientID); client.overID = args.shapeID; - await storage.putClientState(args.clientID, client); + await putClientState(storage, args.clientID, client); } export const overShapeArgs = t.type({ clientID: t.string, diff --git a/shared/shape.ts b/shared/shape.ts index 98ccb99..09fd085 100644 --- a/shared/shape.ts +++ b/shared/shape.ts @@ -1,4 +1,6 @@ import * as t from "io-ts"; +import { must } from "../backend/decode"; +import Storage from "./storage"; export const shape = t.type({ type: t.string, @@ -14,3 +16,26 @@ export const shape = t.type({ }); export type Shape = t.TypeOf; + +export async function getShape( + storage: Storage, + id: string +): Promise { + const jv = await storage.getObject(key(id)); + if (!jv) { + return null; + } + return must(shape.decode(jv)); +} + +export function putShape( + storage: Storage, + id: string, + shape: Shape +): Promise { + return storage.putObject(key(id), shape); +} + +function key(id: string): string { + return `shape-${id}`; +} diff --git a/shared/storage.ts b/shared/storage.ts new file mode 100644 index 0000000..df2523b --- /dev/null +++ b/shared/storage.ts @@ -0,0 +1,9 @@ +import { JSONValue } from "replicache"; + +/** + * Interface required of underlying storage. + */ +export default interface Storage { + getObject(key: string): Promise; + putObject(key: string, value: JSONValue): Promise; +}