Experiments >

Command pattern with cloud functions

Experiment #15224th April, 2021by Joshua Nussbaum

For the code animation project, I am planning to use a command store.

In my initial prototypes, I used a svelte stores to represent the data, but svelte stores means local state. So this experiment explores using remote state the cloud, with the local stores being only a reflection.

Cloud functions

The commands mutate state in a cloud function and save the results in supabase:

Execute function

The execute function runs a command handler, increments the “pointer”, and saves a record of the command in the animation_commands table.

// routes/animations/[id]/commands/execute.js
import { client } from '$lib/db'
import * as commands from '$lib/commands'

export async function post(req) {
  // create client for the specific user
  const supabase = client(req.headers.authorization)

  const { id } = req.params
  const { name, command: commandName, args } = req.body
  const command = commands[commandName]

  // if command handler wasn't found, we cannot continue
  if (!command) {
    return {
      status: 406,
      body: `Unknown command ${commandName}`
    }
  }

  // find the animation
  const { data: animation, error } = await supabase
    .from('animations')
    .select('*')
    .match({id})
    .single()

  // if animation wasn't found return 404
  if (animation == null) {
    return {
      status: 404,
      body: 'Animation not found'
    }
  }

  const {state: updated, previous} = command.execute(animation, args)
  updated.pointer = animation.pointer + 1

  // update animation. triggers insert in `animation_versions` table
  await supabase
    .from('animations')  
    .update(updated)
    .match({id})

  // wipe out all commands past the pointer index
  // this is just in case we undid commands
  // then if we insert a new command,
  // we cannot redo commands that are ahead in the stack
  await supabase
    .from('animation_commands')
    .delete()
    .eq('animation_id', id)
    .gte('pointer', updated.pointer)

  // save a record of the command
  await supabase
    .from('animation_commands')
    .insert({
      animation_id: id,
      user_id: animation.user_id, // not needed once policy is setup
      index: updated.pointer,
      type: commandName,
      args,
      previous
    })

  return {
    status: 200,
    body: JSON.stringify(updated)
  }
}

Undo function

The undo function, get the command at the current pointer, and runs the command handler in reverse to restore the previous state. In then persists that state in the db.

// routes/animations/[id]/commands/undo.js
import { client } from '$lib/db'
import * as commands from '$lib/commands'

export async function post(req) {
  const supabase = client(req.headers.authorization)
  const { id } = req.params

  // find the animation
  const { data: animation } = await supabase
    .from('animations')
    .select('*')
    .match({id})
    .single()

  // return 404 if animation wasn't found
  if (animation == null) {
    return {
      status: 404,
      body: 'Animation not found'
    }
  }

  // if the pointer is zero, the are no steps to undo
  if (animation.pointer == 0) {
    return {
      status: 406,
      body: 'There are no changes to undo.'
    }
  }

  // pull down the command record
  const { data: log } = await supabase
    .from('animation_commands')
    .select('*')
    .match({animation_id: id, counter: animation.pointer})
    .single()

  // get the handler
  const command = commands[log.type]
  // undo the command
  const updated = command.undo(animation, log.args, log.previous)
  // decrement the pointer
  updated.pointer = animation.pointer - 1

  // update the database
  const { data: returnedData } = await supabase
    .from('animations')  
    .update(updated, { returning: 'representation' })
    .match({id})

  // return the updated data
  return {
    status: 200,
    body: JSON.stringify(returnedData)
  }
}

Redo function

The redo function is similar to undo, except it calls handle.execute() instead of handler.undo().

import { client } from '$lib/db'
import * as commands from '$lib/commands'

export async function post(req) {
  const supabase = client(req.headers.authorization)
  const { id } = req.params

  // get the animation data
  const { data: animation } = await supabase
    .from('animations')
    .select('*')
    .match({id})
    .single()

  // return 404 if no record found
  if (animation == null) {
    return {
      status: 404,
      body: 'Animation not found'
    }
  }

  // find the command at the pointer
  const { data: log } = await supabase
    .from('animation_commands')
    .select('*')
    .match({animation_id: id, counter: animation.pointer})
    .single()

  // if this the last command, we can't redo anything
  if (animation.pointer == log.counter) {
    return {
      status: 406,
      body: 'There are no changes to redo.'
    }
  }

  // find the command handler
  const command = commands[log.type]
  // redo the command
  const { state: updated } = command.execute(animation, log.args)
  // increment the pointer
  updated.pointer = animation.pointer + 1

  // update the database
  const { data: returnedData } = await supabase
    .from('animations')
    .update(updated, { returning: 'representation' })
    .match({id})

  // return the updated data
  return {
    status: 200,
    body: JSON.stringify(returnedData)
  }
}

Database client abstraction

A client can be created with or without user authorization, so the authorization is optional. I created a utility function to help with this:

// lib/client.js
import { createClient } from 'supabase'

// create a public client with or without user authorization
export function client(authorization = null) {
  return createClient(
    process.env.SUPABASE_URL,
    process.env.SUPABASE_ANON_KEY
    { headers: { authorization } }
  )
}

Versions

All changes made to the animations table are captured with a trigger, and an copy is inserted into the animation_versions table.

It might seem like it’s duplicating the animation_commands table, but it’s not. The versions table is an append-only log, showing each step that was made. The commands table’s records can be deleted. For example, if several changes are undone and new change is inserted, the undone changes are deleted, because they can no longer be applied.

Here’s how the trigger is defined:

create or replace function handle_updated_animation()
returns trigger as $$
begin
  insert into animation_versions (animation_id, user_id, name, data, timestamp)
  values (old.id, old.user_id, old.name, old.data, old.updated_at);

  return new;
end;
$$ language plpgsql security definer;

create trigger on_animation_updated
  after update on animations
  for each row execute procedure handle_updated_animation();

Code

https://github.com/joshnuss/supabase-command-test

Demo

Notes

  • Multiple writes can impact data consitency, therefore they really should happen inside a transaction. Since transactions are not yet supported, a fallback is to use a stored procedure, or accept that incosistency can happen, and punt it down the road, like I’m doing here.
  • Thise code can be refactored a lot. I prefer not to do queries inside an endpoint/cloud function, but this is just a rough draft.
view all experiments

Stay tuned in

Learn how to add more experimentation to your workflow