Back

Designing the Schema for a Plant Watering App

StatelyDB is a friendlier way to use DynamoDB! If you’ve wanted the scalability of DynamoDB but found the developer experience frustrating, we’re building a better path. Our elastic schema combines the best of NoSQL and relational databases so you can build faster and stop worrying about every little decision.

StatelyDB has a lot of great features, but the one we’re most excited about is Elastic Schema. You can declare the shape of your data in TypeScript code in your repository, then use it to generate typed objects in your favorite language. Then you can update your schema however you want to keep up with new features. StatelyDB will instantly present your existing stored data with the new schema’s shape, while providing automatic backwards compatibility so your old clients don’t need to be upgraded until you’re ready.

We have a bunch of documentation about how to declare and use schemas, but I wanted to walk through a simple example to give an overview of what it looks like to design a schema in StatelyDB. It’s traditional to explain a “To Do List” app, but frankly those are overplayed and tend to be too simple compared to a real application. So instead, we’ll be building something just a little more complicated: a app that keeps track of when you’ve watered all your plants. This has nothing to do with the fact that my house is slowly being taken over by collectible plants.

The app is still pretty simple. Each user has a list of plants in their collection. Each plant can be in a different custom location (bedroom, kitchen, living room). And each plant has a log of when it was last watered.

To start off, we want to define some item types to model the main objects in our app. We’ll have a Plant, a Location, and a WateringLog. Then we have a User that owns all of these.

User

Let’s start by defining a User in a schema.ts file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import {
  bytes,
  itemType,
  string,
  timestampMilliseconds,
  uint,
  url,
  uuid,
} from "@stately-cloud/schema";

export const User = itemType("User", {
  keyPath: "/user-:id",
  fields: {
    id: {
      type: uuid,
      fieldNum: 1,
      initialValue: "uuid",
    },
    name: {
      type: string,
      fieldNum: 2,
    },
    email: {
      type: string,
      fieldNum: 3,
      valid: 'this.matches("[^@]+@[^@]+")',
    },
    hashedPassword: {
      type: bytes,
      fieldNum: 4,
    },
  },
});

This is a pretty straightforward User item:

  • We have an ID, the user’s name, and their email and password.
  • Each field gets a data type and a field number (which must be unique).
  • We let StatelyDB choose the ID as a UUID via the initialValue setting.
  • The key path for users is /user-:id - Users are our top level container so they form the root of the key paths for all other items.

Location

Each plant lives in a location in the house, so let’s define an item for locations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const Location = itemType("Location", {
  keyPath: "/user-:userId/loc-:id",
  fields: {
    id: {
      type: uint,
      fieldNum: 1,
      initialValue: "sequence",
    },
    userId: {
      type: uuid,
      fieldNum: 2,
    },
    name: {
      type: string,
      fieldNum: 3,
    },
    picture: {
      type: url,
      fieldNum: 4,
    },
  },
});

Again, just some simple fields. Some bits to note:

  • We reference the userId from above since Locations are owned by Users.
  • Each location gets a user-supplied name, and a representative picture.
  • The key path for locations is under the user’s key path: /user-:userId/loc-:id — we can get any location given a User’s ID, and a Location’s ID, or we can get all of a User’s Locations at once with List.
  • We let Stately choose the ID here, but instead of a UUID we use a “sequence” that counts up for each User.

Plants

The Locations have a number of Plants in them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export const Plant = itemType("Plant", {
  keyPath: "/user-:userId/loc-:locationId/plant-:id",
  fields: {
    id: {
      type: uuid,
      fieldNum: 1,
      initialValue: "uuid",
    },
    userId: {
      type: uuid,
      fieldNum: 2,
    },
    locationId: {
      type: uint,
      fieldNum: 3,
    },
    name: {
      type: string,
      fieldNum: 4,
    },
    scientificName: {
      type: string,
      fieldNum: 5,
    },
    picture: {
      type: url,
      fieldNum: 6,
    },
  },
});

This is a third level of nesting - User → Location → Plant. If we wanted to call Get on this Plant, we’d need the user ID, location ID, and plant ID because all of those make up the key path. But that’s not a problem—we’ll only ever be grabbing plants en masse via List operations, not Get.

WateringLog

Lastly, we’ll add a log entry for each time we water a plant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const WateringLog = itemType("WateringLog", {
  keyPath: "/plant-:plantId/log-:id",
  fields: {
    id: {
      type: uint,
      fieldNum: 1,
      initialValue: "sequence",
    },
    userId: {
      type: uuid,
      fieldNum: 2,
    },
    plantId: {
      type: uuid,
      fieldNum: 3,
    },
    wateredAt: {
      type: timestampMilliseconds,
      fieldNum: 4,
      fromMetadata: "createdAtTime",
    },
  },
});

This is about as simple as it gets:

  • The key path is just a plant ID and a log ID—we’ll fetch each plant log separately, so we don’t need them nested under a User (we still have a user ID so we can check permissions).
  • For the timestamp, we use fromMetadata to access the built-in “createdAtTime” metadata that Stately tracks for every item—no need to do that bookkeeping ourselves.

Building The App

I’m not going to show how to build out a backend-for-frontend API or write a mobile app—that’d make this a book, not a blog post. But let me walk through what operations we’ll need to get the data for our various screens. For this example we’ll assume all the data has already been Put into a Store.

Home Screen and Location Page

On the home screen, the app shows a list of locations, and each location can be tapped to see a list of plants. We can get all the information we need to show both of these screens using a List operation. If our User ID is GvpCWqiQTa6qpfBHR-ktZw, we can get the User, all of that user’s Locations, and all of their Plants by calling BeginList("/user-GvpCWqiQTa6qpfBHR-ktZw"). Since the argument to BeginList is a key path prefix, we get everything that starts with that path, which includes locations and plants:

BeginList("/user-GvpCWqiQTa6qpfBHR-ktZw") returns:

  • /user-GvpCWqiQTa6qpfBHR-ktZw: A User object with the user’s details

  • /user-GvpCWqiQTa6qpfBHR-ktZw/loc-1: A Location object for the Shelves

    • /user-GvpCWqiQTa6qpfBHR-ktZw/loc-1/plant-AJVvLFpVSE6Vzu7X_hPvmg: The Plant object for “Jackie”
    • … more Plants
  • /user-GvpCWqiQTa6qpfBHR-ktZw/loc-2: A Location object for the Living Room

    • … some plants in the Living Room
  • /user-GvpCWqiQTa6qpfBHR-ktZw/loc-2: A Location object for the Kitchen

    • … some plants in the Kitchen

In this single request, we have all the data we need to let the user browse around. And we can hang on to the list token so we don’t have to fetch that data from scratch ever again—from now on we can use Sync to get only the data that changed (new plants, moved locations, etc.)

(Also, if you’re thinking “that’s not what a UUID looks like”, don’t worry. Our UUIDs have the same info as the ones you’re used to, but only take up 22 bytes as strings instead of 36, and only 17 bytes when stored. Every byte counts!)

Plant Watering Log Page

Each plant watering log gets loaded on demand. We use another List operation for this: BeginList("/plant-AJVvLFpVSE6Vzu7X_hPvmg/log", limit=10, direction=DESC). We plug in the plant ID for “Jackie” (AJVvLFpVSE6Vzu7X_hPvmg), and request all the items with this key path prefix - which will only match our WateringLog items. This time, we add a limit to only get the 10 newest entries and set the direction to descending by log sequence.

If the user scrolls to the end of the list, we can use the list token from this BeginList call to call ContinueList and get another 10 log entries and add them onto the bottom until we hit the beginning of the log.

Any time a user hits the “Watered” button, we Put a new WateringLog item to the store:

1
2
3
4
statelydb.put(statelydb.create('WateringLog', {
  userId: user.id,
  plantId: plant.id,
})

We don’t have to specify the WateringLog’s id here—StatelyDB will fill it in with the next sequence number. We also don’t specify the wateredAt timestamp because that’s tracked automatically.

Takeaway

This simple example shows that it doesn’t take a lot to model an application and efficiently fetch data. This little app would only need two APIs for loading data (get home screen info, and get plant log) plus a few APIs for adding data (record log, add plant, add location). We may revisit this example in the future to show how we can evolve this schema to add new functionality without breaking earlier clients. In the meantime, read through our documentation to get a sense of the options available to you for defining schema and working with data.

Ready to get started with StatelyDB?

🚀 Try us out! Visit our console to create a new Store and start building your application with StatelyDB.

Have more questions? We'd love to hear from you! Book a demo with our team to learn more about how you can iterate faster using StatelyDB with an elastic schema.

Our blog

Latest from our blog

/images/posts/dynamodb-is-the-kerbal-space-program-of-databases.jpg
DynamoDB is the Kerbal Space Program of Databases
The reward is worth it, but the grind to get to a good place is rough.
/images/posts/deploying-a-nextjs-app-using-statelydb-on-netlify.png
Deploying a NextJS app using StatelyDB on Netlify
We walk through deploying an example application on Netlify.
/images/posts/getting-bounced-for-a-bad-id.jpg
Getting Bounced for a Bad ID
A cautionary tale about choosing the wrong identifier.

Let's stay in touch.

Join our mailing list to get early access to stay up to date on the future of data management with no regrets.