Applications and operations

Build a PostgreSQL notes application

Build and verify a complete PostgreSQL notes application with separated ownership, safe queries, measured indexes, and a restored backup.

25 minute lesson

~~~

Let’s finish with one runnable project.

Create the roles and database as the PostgreSQL administrator, using your real administrator role in the membership grant:

CREATE ROLE notes_owner NOLOGIN;
CREATE ROLE notes_app LOGIN;
\password notes_app
GRANT notes_owner TO CURRENT_USER
  WITH INHERIT FALSE, SET TRUE;
CREATE DATABASE notes_app OWNER notes_owner;

Connect to notes_app, then create the schema as its owner:

\connect notes_app
SET ROLE notes_owner;

CREATE SCHEMA app AUTHORIZATION notes_owner;

CREATE TABLE app.notes (
  id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE app.tags (
  id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);

CREATE TABLE app.note_tags (
  note_id BIGINT NOT NULL REFERENCES app.notes(id) ON DELETE CASCADE,
  tag_id BIGINT NOT NULL REFERENCES app.tags(id) ON DELETE CASCADE,
  PRIMARY KEY (note_id, tag_id)
);

CREATE INDEX notes_title_idx ON app.notes(title);
RESET ROLE;

Apply the runtime grants and default privileges from the permissions lesson. Set its path:

ALTER ROLE notes_app IN DATABASE notes_app
SET search_path = app, pg_catalog;

Install pg, set DATABASE_URL, and save this as app.mjs:

import pg from 'pg'

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  connectionTimeoutMillis: 5000,
  statement_timeout: 10000,
  application_name: 'notes-lab',
})

try {
  const inserted = await pool.query(
    `INSERT INTO app.notes (title, body)
     VALUES ($1, $2)
     RETURNING id, title`,
    ['Plan the week', 'Choose three priorities']
  )

  const found = await pool.query(
    'SELECT id, title FROM app.notes WHERE id = $1',
    [inserted.rows[0].id]
  )

  console.log(found.rows[0])
} finally {
  await pool.end()
}

Run it with node app.mjs. Then connect as notes_app and confirm that DROP TABLE app.notes fails.

Inspect the title lookup with EXPLAIN (ANALYZE, BUFFERS). Add an archived_at TIMESTAMPTZ column as an expand step, deploy code that tolerates it, and leave destructive cleanup for another migration.

Finally, dump notes_app, restore it into notes_restore, and query the inserted note there. The lab is complete when the runtime restriction, Node query, migration, plan, and restore all work.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →