๐Ÿ—ƒ๏ธ Day 3 โ€” Persist Data With Files

14 min readยทJan 1, 2026

Welcome to Day 3.

In yesterday's lesson, you implemented the add and list commands and made them behave correctly with valid and invalid input.

In today's lesson, you'll make your notes persistent by saving them to disk and loading them back.

You'll learn the minimal filesystem workflow behind many backend projects: read state, update it, write it back โ€” plus the common failure cases around missing or empty files.

By the end of this lesson, notes you add will still be there after you close and reopen your terminal.

Read from and write to a JSON file

Within the src directory, let's create a new directory named storage, and within it, a new file named json_fs.js.

~/projects/
โ””โ”€ lb_notes/
   โ”œโ”€ bin/
   โ”œโ”€ package.json
   โ””โ”€ src/
      โ”œโ”€ cli/
      โ””โ”€ storage/
         โ””โ”€ json_fs.js

Implement the readJSON() function

Within the json_fs.js file, let's export the following function used to read the content of a file and return its content in the form of a JSON object, or otherwise return null if the file doesn't exist or it is zero-lengthed.

src/storage/json_fs.js
import fs from 'node:fs';

export function readJSON(filePath) {
  if (!fs.existsSync(filePath)) {
    return null;
  }

  let data = fs.readFileSync(filePath, 'utf8');
  data = data?.trim();

  if (!data.length) {
    return null;
  }
  return JSON.parse(data);
}

Where:

  • filePath is a string that represents the path to the source file.

Implement the writeJSON() function

Within the json_fs.js file, let's export the following function used to write a JSON object as a string into a file, and automatically create the intermediary directories of its path if they don't exist.

src/storage/json_fs.js
import fs from 'node:fs';
import path from 'node:path';

export function readJSON(filePath) {
  // ...
}

export function writeJSON(filePath, data) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
}

Where:

  • filePath is a string that represents the path to the destination file.
  • data is a JSON object that represents a list of notes.

Update the add command

Let's now update the previous implementation of the add() function so that it either:

  1. Adds the note to the list and saves the list into the specified file on the disk.
  2. Throws an error if the note is empty or the content of the file is invalid.
src/cli/commands/add.js
import { readJSON, writeJSON } from '../../storage/json_fs.js';

export default function add(filePath, note) {
  let data = readJSON(filePath);
  note = note?.trim();

  if (!data) {
    data = { notes: [] };
  }
  else if (!data?.notes || !Array.isArray(data?.notes)) {
    throw new Error('Invalid JSON file');
  }
  else if (!note.length) {
    throw new Error('Invalid note');
  }

  data.notes.push(note);
  writeJSON(filePath, data);
  console.log('Note saved!');
}

Where:

  • filePath is a string that represents the path to the JSON file containing the notes.
  • note is a string that represents a note.

Update the list command

Let's update the previous implementation of the list() function so that it either:

  1. Outputs an empty array if the specified file is empty or the list of notes if it's not.
  2. Throws an error if the content of the file is invalid.
src/cli/commands/list.js
import { readJSON } from '../../storage/json_fs.js';

export default function list(filePath) {
  const data = readJSON(filePath);

  if (!data) {
    console.log([]);
  }
  else if (!data?.notes || !Array.isArray(data?.notes)) {
    throw new Error('Invalid JSON file');
  }
  else {
    console.log(data.notes);
  }
}

Where:

  • filePath is a string that represents the path to the JSON file containing the notes.

Update the CLI router

Let's update the CLI router by declaring a NOTES_FILE_PATH constant that contains the absolute path to the JSON file used to store the notes, in this case data/notes.json, and use this constant as argument when executing the add() and list() functions.

src/cli/index.js
import path from 'node:path';
import parseArgs from './parse_args.js';
import help from './commands/help.js';
import add from './commands/add.js';
import list from './commands/list.js';

export default function run(args) {
  try {
    const { command, value } = parseArgs(args);
    const NOTES_FILE_PATH = path.resolve('data/notes.json');

    if (command === 'help') {
      help();
    }
    else if (command === 'add') {
      add(NOTES_FILE_PATH, value);
    }
    else if (command === 'list') {
      list(NOTES_FILE_PATH);
    }
  } catch(error) {
    console.error(`lb_notes: error: ${error.message}`);
    return 1;
  }
  return 0;
}

Test the CLI

When running the lb_notes command with the add command and valid strings as argument, it should output "Note saved!" each time.

~/projects/lb_notes$ lb_notes add "Get a carton of eggs"
Note saved!
~/projects/lb_notes$ lb_notes add "Get a loaf of bread"
Note saved!

When running the lb_notes command with the list command, it should output a list of notes.

~/projects/lb_notes$ lb_notes list
[ 'Get a carton of eggs', 'Get a loaf of bread' ]

Finally, when outputting the content of the data/notes.json file, it should produce this object.

~/projects/lb_notes$ cat data/notes.json 
{
  "notes": [
    "Get a carton of eggs",
    "Get a loaf of bread"
  ]
}