OpenViSTAI · Agents · Blockchain · Crypto
Menu

OPENVIST LEARN · FREE LESSONS

Project: build a tested local JSON reporting tool

Combine modules, validation, async file reads and a clear command-line boundary.

Lesson 24 of 25 · About 40 minutes · Beginner · English

By the end of this lesson

Run a bounded Node CLI that validates a whole document before reporting its counts.

Before you start

  • Node.js 24 with a terminal and plain-text editor; the browser-only lesson is labelled separately.
  • Complete lesson 23 or be comfortable with its examples.

Three words to know

Input boundary
The point where external bytes enter a program.
Bounded read
Reading at most a limit plus one byte to detect excess.
Whole-document validation
Rejecting the entire document if any record violates the contract.

1. Write the input contract first

The input is one ordinary local UTF-8 JSON file, at most 65,536 bytes. Its top-level array has at most 1,000 records, each with exactly title and status. A title must be nonblank text of at most 100 Unicode code points. status is exactly open or closed. Empty lists are valid. A BOM, invalid UTF-8, malformed JSON or invalid record is rejected.

Read bounded bytes, parse JSON, validate every record and only then emit a summary.
The project validates the whole document before emitting report data.

2. Save the source and fixture

Use your editor or the download buttons to save task_report.mjs, tasks.json and task_report.test.mjs in the same fresh practice folder. The JSON block is data, not a terminal command. The source below matches the downloadable program.

tasks.jsonJSON · save as tasks.json; do not execute
[{"title":"Read","status":"open"},{"title":"Test","status":"closed"},{"title":"Review","status":"open"}]

3. Understand the complete implementation

summarise validates all records and returns plain data. readRecords opens a file, checks its type, reads up to the limit plus one byte and closes the handle in finally. Fatal decoding rejects invalid UTF-8 rather than replacing bytes. main prints one summary only after those steps succeed. The URL comparison at the end calls main for direct execution but keeps imports from running the CLI.

task_report.mjsJAVASCRIPT · Node.js · save as task_report.mjs
// SPDX-License-Identifier: MIT
// Learning project: bounded reporting for ordinary local task files.
import { open } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

export const MAX_BYTES = 65536;
export const MAX_RECORDS = 1000;

export function summarise(records) {
  if (!Array.isArray(records) || records.length > MAX_RECORDS) {
    throw new Error("Expected at most 1000 records");
  }
  const counts = { closed: 0, open: 0 };
  for (const record of records) {
    if (record === null || typeof record !== "object" || Array.isArray(record) ||
        Object.keys(record).sort().join(",") !== "status,title") {
      throw new Error("Expected title and status only");
    }
    const { title, status } = record;
    if (typeof title !== "string" || title.trim().length === 0 || [...title].length > 100) {
      throw new Error("Expected a nonblank title of at most 100 code points");
    }
    if (status !== "open" && status !== "closed") {
      throw new Error("Expected open or closed status");
    }
    counts[status] += 1;
  }
  return { by_status: counts, records: records.length };
}

export async function readRecords(path) {
  const handle = await open(path, "r");
  try {
    if (!(await handle.stat()).isFile()) throw new Error("Expected an ordinary file");
    const buffer = Buffer.alloc(MAX_BYTES + 1);
    let length = 0;
    while (length < buffer.length) {
      const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
      if (bytesRead === 0) break;
      length += bytesRead;
    }
    if (length > MAX_BYTES) throw new Error("Input exceeds 65536 bytes");
    const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true })
      .decode(buffer.subarray(0, length));
    if (text.startsWith("\uFEFF")) throw new Error("BOM is not allowed");
    return JSON.parse(text);
  } finally {
    await handle.close();
  }
}

export async function main(args = process.argv.slice(2)) {
  if (args.length === 1 && args[0] === "--help") {
    console.log("Usage: node task_report.mjs INPUT.json");
    return 0;
  }
  if (args.length !== 1) {
    console.error("error: expected one input file; use --help");
    return 2;
  }
  let result;
  try {
    result = summarise(await readRecords(args[0]));
  } catch {
    console.error("error: invalid or unreadable task file");
    return 2;
  }
  console.log(JSON.stringify(result));
  return 0;
}

if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
  process.exitCode = await main();
}
What you should see · example output
{"by_status":{"closed":1,"open":2},"records":3}

4. Run and inspect

Run the projectTERMINAL · Bash or Windows PowerShell
node task_report.mjs tasks.json
What you should see · example output
{"by_status":{"closed":1,"open":2},"records":3}

Expect three records: one closed and two open. The program does not modify the input. Known invalid input produces no stdout summary, one generic stderr message and status 2. The generic boundary message avoids echoing private input; it does not replace tests or debugging inside the reusable functions. This script is a teaching tool for small files, not a production ingestion service or a security sandbox.

YOUR TURN

A small check before you continue

Change only the last status to pending. Should the program still print a count for the valid first records?

Show a suggested answer

No. It rejects the document with empty stdout, a generic stderr message and status 2. Restore open or closed before checking the normal result again.

Sources and scope

Documentation and examples reviewed 2026-10-18. Provider screens, package versions and output wording can differ. Examples show expected outcomes; they are not a claim that OpenViST connected to your server or verified your setup.