Building web apps with Jnu data binding

Every job you deploy leaves rows in the Scheduler database: the job itself, its slices, its results, its performance figures. Jnu is the part of dcp-client that lets your own programs read those rows. Each query is signed by your identity and checked against the same security policy that the DCP Portal uses, so you see your own data and nothing else, and you never handle database credentials.

In a web page you don’t have to write the queries at all. The Jnu data-binding library, the same library that renders the Jobs page of the Portal, provides a <jnu-record-set> HTML element and a family of jnu-* attributes. You describe the query on the element and mark up where each column goes, and the library fetches, renders, sorts, pages and refreshes.

The division of labour: tags are for showing values that are in the rows: a job’s name, its status, when it started. Anything computed from those values, a count, a percentage, a total, is a few lines of JavaScript that read the same connection the tags use.

This tutorial has two parts:

  • Part 1 builds a page of fifteen lines that shows the completion percentage of one job and keeps it up to date.

  • Part 2 grows it into a small dashboard: a sortable, paged table of all your jobs, a sign-in form, and a details panel for the job you click on.

Both pages were run against the live Scheduler to confirm that they work.

💡 The complete code of each part is at the end of that part.

Before you start

You need:

  • A modern web browser

  • An API key, or a keystore file, for the identity that deployed your jobs (see getting setup)

  • Any static web server for the page, for example python3 -m http.server in the page’s directory

The identity that signs a query decides what comes back. The Scheduler policy for the jobs table returns only rows whose owner is the signing address, and the tables that hang off a job (jobResults, jobPerfData, jobFinData, activeSlices, jobArguments, jobData, …) are reachable only through a job you own. The only columns you can change are name, description and link.

In practice: query with the API key or identity keystore that deployed the jobs you want to look at. If you get an empty result for a job you know exists, the key is almost always the reason.

Two columns of the jobs table are often confused:

  • opaqueId is the public job id, the value of job.id in the Compute API and the id shown in the Portal.

  • id is the database primary key. The other tables refer to a job by this number, in their jobId column.

The columns used in this tutorial:

Table

Columns used

jobs

id, opaqueId, name, status, startTime, lastUpdate, link, lastSliceNumber (slice count), nextSliceNumber

jobResults

id, jobId, sliceNumber, value

The full schema is available at run time: every record set has a schema property, and Object.keys($('#job').schema.tables.jobs) in the browser console is the quickest way to see what else is there.

Part 1: A job completion page

The goal: a page that shows one job’s name, status and the percentage of its slices that have finished, refreshed every ten seconds. Start from an empty HTML file and add the pieces in order.

Step 1: Load the libraries

Two script tags in <head>:

<script src="https://scheduler.distributed.computer/dcp-client/dcp-client.js"></script>
<script
  type="module"
  src="https://cdn.distributed.computer/components/jnu-databinding.js"
></script>

The first is the DCP client bundle you use for any browser DCP application; it defines the dcp global (dcp.identity, dcp.jnu, dcp.wallet, …) and dcpConfig. The second is the Jnu data-binding library, an ES module served from the DCP CDN. It defines the <jnu-record-set> custom element and loads its own dependencies from the same CDN: dcp-dom-tk.js, date-fns and jnu-databinding.css.

Note

dcp-dom-tk.js installs $ and $$ as shorthand for document.querySelector and querySelectorAll, adds .on()/.off() to every EventTarget, exposes a global modules object, and shows uncaught errors and rejections in a modal. It refuses to load if window.$ is already defined, so the data-binding library can’t share a page with jQuery.

Step 2: Sign in

Every <jnu-record-set> waits for dcp.identity to have a session before it runs its first query, so a page that never sets one shows nothing. The shortest way to set one is a third script tag with the API key in it:

<script>
  dcp.identity.set('<api-key>');
</script>

identity.set() accepts anything the Keystore constructor accepts, so a private-key string works as is. As soon as it resolves, every record set on the page that was waiting runs its query.

Anyone who can read the page source can read the key, so this form is only for a key that can do nothing but query, or for a page you don’t publish. Part 2 replaces it with a form that accepts an API key or a keystore file.

Step 3: Find the job

A <jnu-record-set> is one query against one table, described by attributes. This one selects the row of the jobs table whose opaqueId is the job id you want to watch:

<jnu-record-set
  id="job"
  database="scheduler"
  table="jobs"
  autofetch
  limit="2"
  filter-by="opaqueId"
  filter="<job-id>"
>
  <div>
    <template jnu-record-set="job"
      ><h1>
        <span jnu-field="name"></span> (<span jnu-field="status"></span>)
      </h1></template
    >
  </div>
</jnu-record-set>
  • database is the Jnu service name (scheduler, portal or pxAuth) and table the table to query.

  • autofetch runs the query as soon as the element is in the document and the identity is set.

  • filter-by and filter are a one-column equality filter. Anything more complex goes through the element’s selectQuery property, which Step 4 uses.

  • A <template> is stamped out once per row of the result, and jnu-field="column" on the copies is filled in with that column’s value. A template that isn’t under a <tbody> inside the record set needs the jnu-record-set="id" attribute to be found, which is why the <h1> template carries one.

Note

limit="2" rather than 1 for a query that returns one row looks odd. A record set with limit="1" automatically chooses its row on fetch, and as of 2026-10-03 that automatic choice fails on the record set’s first fetch (known issue, expected to be fixed). Any other limit avoids it; the result is one row either way.

Save and open the page now and you have the job’s name and status. The record set is hidden by the bundled stylesheet until its first fetch completes, so the page is blank for a moment rather than showing empty spans.

Step 4: Count the results

The jobs row tells you how many slices a job has (lastSliceNumber) and how many have been handed out (nextSliceNumber - 1), but not how many have come back. That number is the count of jobResults rows that belong to the job. It isn’t a column anywhere, so it isn’t a job for a tag; it’s a function in the script tag from Step 2:

async function showCompletion(job) {
  const done = await $('#job').useDb((db) =>
    db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
  );
  const total = job.lastSliceNumber;
  $('#completion').textContent =
    `${done} of ${total} slices complete (${((100 * done) / total).toFixed(1)}%)`;
}
  • useDb(callback) hands your callback the same Jnu Connection the record set queries through, inside the same queue, with the element’s io-busy attribute set for the duration. No second sign-in and no second schema download are needed.

  • count() is a select variant that resolves with a plain number. prototype is the partial row every counted result has to match, here the job’s database id.

Call it when the job has rendered, with an onrenderfetch handler on the record set from Step 3, and give the sentence somewhere to go:

<jnu-record-set id="job" … onrenderfetch="showCompletion(this.records[0])">
  …
  <p id="completion"></p>
</jnu-record-set>

on… attributes on a record set are event handlers, evaluated with this set to the element, like the built-in onclick. The library fires beforefetch, fetch, renderfetch, renderrecord, refresh, renderrefresh, choose, unchoose, ready and others. renderfetch fires once the rows of a fetch are in the DOM, so by then this.records[0] is the job.

Reload the page and the line “10 of 10 slices complete (100.0%)” appears under the job’s name and status.

Step 5: Keep it live

One more line in the script tag from Step 2:

setInterval(() => $('#job').fetch(), 10e3);

Re-fetching the job fires its onrenderfetch handler again, which re-counts the results. Database calls from every record set and useDb() call on a page go through one queue, so a slow response and the next tick don’t race each other.

Part 1: Complete code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Job completion</title>
    <script src="https://scheduler.distributed.computer/dcp-client/dcp-client.js"></script>
    <script
      type="module"
      src="https://cdn.distributed.computer/components/jnu-databinding.js"
    ></script>
    <script>
      /* The identity that signs the queries. Record sets wait for this before they fetch. */
      dcp.identity.set('<api-key>');

      /* Finished slices = rows in jobResults for this job. Counting is one query on the same connection. */
      async function showCompletion(job) {
        const done = await $('#job').useDb((db) =>
          db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
        );
        const total = job.lastSliceNumber;
        $('#completion').textContent =
          `${done} of ${total} slices complete (${((100 * done) / total).toFixed(1)}%)`;
      }

      /* Re-read the job every 10 s; onrenderfetch below re-counts the results. */
      setInterval(() => $('#job').fetch(), 10e3);
    </script>
  </head>
  <body>
    <!-- Look up the job by its public id. There is one match, rendered through the template. -->
    <jnu-record-set
      id="job"
      database="scheduler"
      table="jobs"
      autofetch
      limit="2"
      filter-by="opaqueId"
      filter="<job-id>"
      onrenderfetch="showCompletion(this.records[0])"
    >
      <div>
        <template jnu-record-set="job">
          <h1>
            <span jnu-field="name"></span> (<span jnu-field="status"></span>)
          </h1>
        </template>
      </div>
      <p id="completion"></p>
    </jnu-record-set>
  </body>
</html>

Part 2: A jobs dashboard

Part 1 watches one job whose id is in the page. The dashboard lists all the jobs the identity owns in a table that can be sorted and paged, lets you click a job to open a details panel with its completion count, and refreshes every ten seconds. The key moves out of the page source and into a sign-in form. Everything from Part 1 still applies; this part adds to it. The split stays the same: tags show the columns, and the one computed number, the completion count, comes from useDb().

Signing in with a form

The form accepts either an API key or a keystore file and passphrase:

async function connect(form) {
  const privateKey = form.elements.privateKey.value.trim();
  const file = form.elements.keystore.files[0];

  if (privateKey) await dcp.identity.set(privateKey);
  else if (file) {
    const passphrase = form.elements.passphrase.value;
    const keystore = await new dcp.wallet.Keystore(
      await file.text(),
      passphrase,
    );
    await keystore.unlock(passphrase);
    await dcp.identity.set(keystore);
  }
  // ...
}

A Keystore built from a file is locked; unlock() it first, or pass { passphrase } as the second argument to set(). Until set() resolves, the record sets on the page sit waiting; the moment it does, they fetch.

Note

An application for Portal users rather than API-key holders can call dcp.identity.login() instead, which takes the user through the DCP authentication pages and back, leaving the page with a reduced-privilege proxy key; pages inside the Portal itself get the identity from the parent frame with the inherit-identity attribute on the dcp-client.js script tag. Either way, once a session exists, the record sets behave exactly as described here.

The record set element

The jobs record set now has no filter, so it returns every job the identity owns, ten at a time, newest first:

<jnu-record-set
  id="jobs"
  database="scheduler"
  table="jobs"
  autofetch
  limit="10"
  order-by="startTime"
  order-dir="desc"
  onchoose="loadResults(this.chosen)"
>
  …
</jnu-record-set>

Attribute

Meaning

database

Jnu service name: scheduler, portal or pxAuth.

table

Table (strictly, security-policy name) to query.

autofetch

Run the query as soon as the element is in the document, and again whenever page, limit, order-by, order-dir, filter-by or filter change. Without it, call .fetch() yourself.

limit, order-by, order-dir

Page size, sort column and asc/desc. Changing limit keeps the current position and recomputes the page.

filter-by, filter

A one-column equality filter, for example filter-by="status" filter="running". Anything more complex goes through the element’s selectQuery property.

onfetch, onrenderfetch, onrefresh, onchoose, onunchoose, …

Event handlers, evaluated with this set to the element and event in scope. The library fires beforefetch, fetch, renderfetch, renderrecord, refresh, renderrefresh, choose, unchoose, ready, dirtychange and synchronize.

The element also reports its state as attributes you can style against or read: page, page-count, total-row-count (rows matching the query, regardless of limit), record-count (rows in memory), record-id (the chosen record), io-busy and initialized. The bundled stylesheet hides a record set until it’s initialized and disables pointer events while it’s io-busy.

Rendering rows with templates

A <template> placed as the only child of a <tbody> inside the record set is stamped out once per row, with no jnu-record-set attribute needed, and the jnu-* attributes on the copies are filled in from the record:

<tbody>
  <template>
    <tr
      jnu-attributes="status: status"
      onclick="this.jnuRecordSet.choose(this.jnuRecord)"
    >
      <td jnu-field="name"></td>
      <td jnu-field="status"></td>
      <td jnu-field="opaqueId" class="mono"></td>
      <td jnu-field="startTime" jnu-date-fmt="ago"></td>
      <td>
        <span
          jnu-field="nextSliceNumber"
          jnu-render="this.textContent = value - 1"
        ></span>
        / <span jnu-field="lastSliceNumber"></span>
      </td>
      <td></td>
    </tr>
  </template>
</tbody>
  • jnu-field="column" renders the column’s value into the element’s text, or into its value for <input>, <select> and <textarea>. Dates are formatted for the user’s locale; jnu-date-fmt="ago" gives “3 days ago” instead, and any other value is passed to date-fns as a format string.

  • jnu-render="…" is JavaScript run instead of the default rendering, with this set to the element and fieldName, value and record in scope. Return false to fall through to the default rendering afterwards.

  • jnu-attributes="attr: column, attr2: column2" renders columns into attributes instead of content. Here it copies the job’s status onto the row, so the stylesheet can bold running jobs with tr[status="running"]. On the details panel the same mechanism turns a link column into an href.

  • Every element rendered from a record gets jnuRecord and jnuRecordSet properties, which is how the row’s onclick chooses itself.

Sorting, paging and refreshing

These need no JavaScript at all:

<thead>
  <tr>
    <th jnu-order-by="name">Name</th>
    <th jnu-order-by="status">Status</th>
    …
    <th><button jnu-nav="refresh">Refresh</button></th>
  </tr>
</thead>
…
<tfoot>
  <tr>
    <td colspan="6">
      <button jnu-nav="prev">&lsaquo; Prev</button>
      Page
      <span jnu-onfetch="this.textContent = this.jnuRecordSet.page"></span> of
      <span jnu-onfetch="this.textContent = this.jnuRecordSet.pageCount"></span>
      <button jnu-nav="next">Next &rsaquo;</button>
      (<span
        jnu-onfetch="this.textContent = this.jnuRecordSet.totalRowCount"
      ></span>
      jobs)
    </td>
  </tr>
</tfoot>
  • Clicking a <th jnu-order-by="column"> sorts by that column; clicking it again flips the direction. The stylesheet adds ▲/▼ indicators.

  • jnu-nav buttons move through the pages: first, prev, next, last; pagesz on a <select> changes the page size; refresh re-reads the rows currently on screen and re-renders only those that changed, while reload (or shift-clicking refresh) re-runs the whole query.

  • The classes jnu-hide-when-empty-set and jnu-show-when-empty-set, on direct children of the record set (or on its table’s rows and cells), show the table or a “no jobs” message depending on total-row-count.

Choosing a record

Elements with jnu-field that are inside the record set but outside any template are bound to the chosen record, the one passed to .choose(record). Until something is chosen, they’re empty; the page uses the record-id attribute to keep the panel hidden until then:

#jobs:not([record-id]) .details {
  display: none;
}
<section class="details">
  <h2 jnu-field="name"></h2>
  <dl>
    <dt>Job Id</dt>
    <dd jnu-field="opaqueId" class="mono"></dd>
    <dt>Status</dt>
    <dd jnu-field="status"></dd>
    <dt>Started</dt>
    <dd jnu-field="startTime"></dd>
    <dt>Last update</dt>
    <dd jnu-field="lastUpdate" jnu-date-fmt="ago"></dd>
    <dt>Link</dt>
    <dd>
      <a jnu-field="link" jnu-attributes="href: link" target="_blank"></a>
    </dd>
    …
  </dl>
  <button jnu-action="unchoose">Close</button>
</section>

jnu-action="unchoose" clears the choice; the chosen row also carries a jnu-chosen-record attribute for styling. If a form in the panel has <input> elements whose name matches a column, the library keeps the chosen record in sync as the user types and jnu-action="save-changes" writes it back; the Portal’s Jobs page uses this to let you edit a job’s name, description and link. This page is read-only and doesn’t use that part.

Counting results in the details panel

The completion count is the same count() query as in Part 1, run for the chosen job instead of a fixed one. The jobs record set’s onchoose handler calls it, and the details panel has an element for the answer:

<dt>Completed</dt>
<dd id="completion"></dd>
async function loadResults(job) {
  const jobs = document.getElementById('jobs');
  const done = await jobs.useDb((db) =>
    db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
  );
  const total = job.lastSliceNumber ?? 0;
  const pct = total > 0 ? ((done / total) * 100).toFixed(1) + '%' : 'N/A';
  document.getElementById('completion').textContent =
    `${done} / ${total} slices (${pct})`;
}

Note

A page can have more than one record set when a second table’s columns belong on screen as values rather than as a computed number. The Portal’s Jobs page does this with jobPerfData, showing CPU and GPU time beside each chosen job. Elements bind to the nearest enclosing record set; jnu-record-set="id" on an element points it at a different one by id. Two things to know if you go that way: jnu-on* handler attributes are only wired up for elements inside their own record set element or its templates, and a record set with limit="1" automatically chooses its row on fetch, which fails on its first fetch as of 2026-10-03.

Live updates

Once signed in, the page refreshes itself:

setInterval(() => {
  const jobs = document.getElementById('jobs');
  jobs.refresh();
  if (jobs.chosen) loadResults(jobs.chosen);
}, 10e3);

Unlike Part 1, this calls refresh() rather than fetch() on the jobs record set. refresh() re-reads only the records already on screen, by id, and re-renders the ones whose data changed, so a page that’s just watching a running job doesn’t repaint on every tick.

Using the connection directly

useDb() is the general escape hatch, not just for counting: anything the Connection can do, including search, groupCount and the aggregate variants, is available inside its callback. Outside a record set, dcp.jnu.connect('scheduler') gives you the same object once an identity is set.

Part 2: Complete code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>DCP Job Completion</title>

    <!-- 1. The DCP client bundle: defines the dcp global (dcp.identity, dcp.jnu, dcp.wallet, ...) and dcpConfig. -->
    <script src="https://scheduler.distributed.computer/dcp-client/dcp-client.js"></script>

    <!-- 2. Jnu data binding: defines the <jnu-record-set> custom element and the jnu-* attributes.
            It pulls in its own dependencies (dcp-dom-tk.js, date-fns, jnu-databinding.css) from the CDN. -->
    <script
      type="module"
      src="https://cdn.distributed.computer/components/jnu-databinding.js"
    ></script>

    <script>
      /**
       * Every <jnu-record-set> waits for dcp.identity to have a session before it runs its first
       * query, so the page shows nothing until this function has run. The identity decides which
       * jobs the scheduler returns: only jobs whose owner is this address.
       */
      async function connect(form) {
        const status = document.getElementById('connect-status');
        const privateKey = form.elements.privateKey.value.trim();
        const file = form.elements.keystore.files[0];

        try {
          if (privateKey) await dcp.identity.set(privateKey);
          else if (file) {
            const passphrase = form.elements.passphrase.value;
            const keystore = await new dcp.wallet.Keystore(
              await file.text(),
              passphrase,
            );
            await keystore.unlock(passphrase);
            await dcp.identity.set(keystore);
          } else throw new Error('Enter an API key or choose a keystore file');
        } catch (error) {
          status.textContent = error.message;
          return;
        }

        form.hidden = true;
        document.getElementById('whoami').textContent =
          dcp.identity.get().address;

        /* Live data: every 10 s, re-read the rows on screen and re-render only the ones that changed. */
        setInterval(() => {
          const jobs = document.getElementById('jobs');
          jobs.refresh();
          if (jobs.chosen) loadResults(jobs.chosen);
        }, 10e3);
      }

      /**
       * Count the finished slices of the chosen job. There is no column for this on the jobs table: it
       * is the number of jobResults rows for the job, so it is one count query on the connection the
       * record set already has. Tags are for showing values that are in the rows; anything computed is
       * done here in JavaScript.
       */
      async function loadResults(job) {
        const jobs = document.getElementById('jobs');
        const done = await jobs.useDb((db) =>
          db.count({ table: 'jobResults', prototype: { jobId: job.id } }),
        );
        const total = job.lastSliceNumber ?? 0;
        const pct = total > 0 ? ((done / total) * 100).toFixed(1) + '%' : 'N/A';
        document.getElementById('completion').textContent =
          `${done} / ${total} slices (${pct})`;
      }
    </script>

    <style>
      body {
        font-family: system-ui, sans-serif;
        margin: 2em;
      }
      table {
        border-collapse: collapse;
      }
      th,
      td {
        padding: 0.3em 0.8em;
        border-bottom: 1px solid #ccc;
        text-align: left;
      }
      tbody tr:hover {
        background: #eef;
        cursor: pointer;
      }
      tr[status='running'] td {
        font-weight: bold;
      }
      tr[status='cancelled'] td {
        color: #999;
      }
      tbody tr[jnu-chosen-record] {
        background: #ffd;
      }
      .mono {
        font-family: monospace;
      }
      /* The details panel is only meaningful once a row has been chosen */
      #jobs:not([record-id]) .details {
        display: none;
      }
      .details {
        border: 1px solid #ccc;
        padding: 1em;
        margin-top: 1em;
        max-width: 40em;
      }
      .details dt {
        font-weight: bold;
        float: left;
        clear: left;
        width: 10em;
      }
    </style>
  </head>

  <body>
    <h1>Job Completion</h1>

    <form id="connect" onsubmit="connect(this); return false;">
      <p>
        API key (private key):
        <input name="privateKey" type="password" size="70" />
      </p>
      <p>
        or keystore file:
        <input
          name="keystore"
          type="file"
          accept=".keystore,application/json"
        />
        passphrase: <input name="passphrase" type="password" />
      </p>
      <button type="submit">Connect</button>
      <span id="connect-status"></span>
    </form>
    <p>Signed in as <span id="whoami" class="mono"></span></p>

    <!-- The jobs record set: one query against the scheduler's jobs table, described by attributes. -->
    <jnu-record-set
      id="jobs"
      database="scheduler"
      table="jobs"
      autofetch
      limit="10"
      order-by="startTime"
      order-dir="desc"
      onchoose="loadResults(this.chosen)"
    >
      <table class="jnu-hide-when-empty-set">
        <thead>
          <tr>
            <th jnu-order-by="name">Name</th>
            <th jnu-order-by="status">Status</th>
            <th>Job Id</th>
            <th jnu-order-by="startTime">Created</th>
            <th>Distributed</th>
            <th><button jnu-nav="refresh">Refresh</button></th>
          </tr>
        </thead>
        <tbody>
          <!-- One copy of the template is stamped out per row of the result set -->
          <template>
            <tr
              jnu-attributes="status: status"
              onclick="this.jnuRecordSet.choose(this.jnuRecord)"
            >
              <td jnu-field="name"></td>
              <td jnu-field="status"></td>
              <td jnu-field="opaqueId" class="mono"></td>
              <td jnu-field="startTime" jnu-date-fmt="ago"></td>
              <td>
                <span
                  jnu-field="nextSliceNumber"
                  jnu-render="this.textContent = value - 1"
                ></span>
                / <span jnu-field="lastSliceNumber"></span>
              </td>
              <td></td>
            </tr>
          </template>
        </tbody>
        <tfoot>
          <tr>
            <td colspan="6">
              <button jnu-nav="prev">&lsaquo; Prev</button>
              Page
              <span
                jnu-onfetch="this.textContent = this.jnuRecordSet.page"
              ></span>
              of
              <span
                jnu-onfetch="this.textContent = this.jnuRecordSet.pageCount"
              ></span>
              <button jnu-nav="next">Next &rsaquo;</button>
              (<span
                jnu-onfetch="this.textContent = this.jnuRecordSet.totalRowCount"
              ></span>
              jobs)
            </td>
          </tr>
        </tfoot>
      </table>

      <p class="jnu-show-when-empty-set">No jobs found for this identity.</p>

      <!-- Elements outside the template bind to the chosen record -->
      <section class="details">
        <h2 jnu-field="name"></h2>
        <dl>
          <dt>Job Id</dt>
          <dd jnu-field="opaqueId" class="mono"></dd>
          <dt>Status</dt>
          <dd jnu-field="status"></dd>
          <dt>Started</dt>
          <dd jnu-field="startTime"></dd>
          <dt>Last update</dt>
          <dd jnu-field="lastUpdate" jnu-date-fmt="ago"></dd>
          <dt>Link</dt>
          <dd>
            <a jnu-field="link" jnu-attributes="href: link" target="_blank"></a>
          </dd>
          <dt>Completed</dt>
          <dd id="completion"></dd>
        </dl>
        <button jnu-action="unchoose">Close</button>
      </section>
    </jnu-record-set>
  </body>
</html>

The same query from Node.js

Nothing in this tutorial is browser-specific except the data binding. A Node.js program uses the same tables through the Jnu API directly: pass the keystore to connect(), select() the job, and count() its results.

const wallet = require('dcp/wallet');
const jnu = require('dcp/jnu');

const db = await jnu.connect('scheduler', await wallet.get('id'));
const job = await (
  await db.select({ table: 'jobs', prototype: { opaqueId } })
).getFirst();
const completed = await db.count({
  table: 'jobResults',
  prototype: { jobId: job.id },
});
console.log(`${completed}/${job.lastSliceNumber}`);

A complete command-line version of this application is in the dcp/jnu API reference.

Further reading