Skip to content
Back to skills

Linkly

ASecurity

Linkly's links live in `state`, which you set to in-memory back in Chapter 1. Restart the engine and everything is gone. In this chapter you add a `database` worker (SQLite) that holds the durable record of links and a timestamped row for every click on a short code. `state` stays in the picture as a fast read cache in front of the database. <Info> `state` can also persist on its own (`store_method: file_based` with a `file_path`). This chapter uses a dedicated `database` worker instead, whic...

  • 18,821 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 20, 2026
developmenttypescriptgobashsqldatabase

Works with

  • cli

Security analysis

A100/100

Pro scans all 17 files and shows the line behind each finding

Scanned September 20, 2026

npx -y skills add iii-hq/iii --skill linkly --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Linkly?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Linkly
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/iii-hq-linkly-2e5e7091/badge)](https://www.skillsdirectory.com/skills/iii-hq-linkly-2e5e7091)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
<!-- generated by iii-skill-render. DO NOT EDIT (changes here are overwritten on the next render). Edit docs/next/tutorials/linkly/persistence.mdx. -->

# Ch. 3: Persist everything


Linkly's links live in `state`, which you set to in-memory back in Chapter 1. Restart the engine
and everything is gone. In this chapter you add a `database` worker (SQLite) that holds the durable
record of links and a timestamped row for every click on a short code. `state` stays in the
picture as a fast read cache in front of the database.

<Info>
  `state` can also persist on its own (`store_method: file_based` with a `file_path`). This
  chapter uses a dedicated `database` worker instead, which gives you durable storage plus SQL to
  query it.
</Info>

## Add the database worker

State is a fast cache, but you also want a durable record you can run SQL over: every link, and a
timestamped row each time someone follows one. Add the `database` worker:

```bash
iii trigger -n linkly compose::add worker=database
mkdir -p data
```

The engine has been running since Chapter 1, so it starts the `database` worker as soon as it is
added, and generates its settings file at `./config/database.yaml` (see
[Configuration](/using-iii/configuration)). Open that file: the generated defaults already point at
a SQLite database at `./data/iii.db`, so there's nothing to change here. This is what durable
storage looks like:

<Info>The database worker will automatically create `./data/iii.db` on first run.</Info>

<Info>
  The database worker supports more than SQLite, refer to the [`database` worker
  docs](https://workers.iii.dev/workers/database) for all supported databases.
</Info>

```yaml config/database.yaml
id: database
name: Database
value:
  databases:
    primary:
      pool:
        acquire_timeout_ms: 5000
        idle_timeout_ms: 30000
        max: 10
      url: sqlite:./data/iii.db
```

The worker will be in charge of defining its own schema. We'll build up the necessary changes to
`link/src/index.ts` in pieces.

### Define the database

First add the `DB` constant **near the top of `link/src/index.ts`**:

```typescript {4} src/index.ts
import { registerWorker } from "iii-sdk";
import { Logger } from "@iii-dev/helpers/observability";

const DB = "primary"; // Matches db name in config/database.yaml
```

### Make link storage persistent

Now we're going to adapt the existing `link::create` and `link::resolve` functions so that they
write and read from our new database while using our state worker as a hot cache.

#### Create a schema

Add an `ensureSchema()` function at the end of `link/src/index.ts` that creates both tables on
startup. The database worker accepts SQL through its `database::execute` function:

```typescript src/index.ts
async function ensureSchema(): Promise<void> {
  await worker.trigger({
    function_id: "database::execute",
    payload: {
      db: DB,
      sql: "CREATE TABLE IF NOT EXISTS links (code TEXT PRIMARY KEY, url TEXT NOT NULL, created_at TEXT NOT NULL)",
    },
  });
  await worker.trigger({
    function_id: "database::execute",
    payload: {
      db: DB,
      sql: "CREATE TABLE IF NOT EXISTS clicks (id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, clicked_at TEXT NOT NULL)",
    },
  });
}

ensureSchema()
  .then(() => logger.info("database: ready"))
  .catch((err) => logger.error("database: schema init failed", { error: String(err) }));
```

#### Setup database writing

**Modify `link::create`** to write to both the database (durable record) and `state` (hot
cache):

```typescript src/index.ts {4-11}
worker.registerFunction("link::create", async (payload: { url: string; code?: string }) => {
  const code = payload.code ?? makeCode();
  const url = /^https?:\/\//i.test(payload.url) ? payload.url : `https://${payload.url}`;
  await worker.trigger({
    function_id: "database::execute",
    payload: {
      db: DB,
      sql: "INSERT INTO links (code, url, created_at) VALUES (?, ?, ?)",
      params: [code, url, new Date().toISOString()],
    },
  });
  await worker.trigger({
    function_id: "state::set",
    payload: { scope: "links", key: code, value: { url } },
  });
  logger.info("link created", { code, url });
  return { code, url };
});
```

#### Setup database retrieval

**Modify `link::resolve` to check the cache first**; on a miss, fall back to the database and warm
the cache for the next read. It's easiest to replace the existing `link::resolve` function with our
new version:

```typescript src/index.ts
worker.registerFunction("link::resolve", async (payload: { code: string }) => {
  const cached = await worker.trigger<{ scope: string; key: string }, { url: string } | null>({
    function_id: "state::get",
    payload: { scope: "links", key: payload.code },
  });
  if (cached) {
    logger.info("link resolved", { code: payload.code, found: true });
    return { url: cached.url };
  }
  const { rows } = await worker.trigger<
    { db: string; sql: string; params: string[] },
    { rows: Array<{ url: string }> }
  >({
    function_id: "database::query",
    payload: { db: DB, sql: "SELECT url FROM links WHERE code = ?", params: [payload.code] },
  });
  const url = rows[0]?.url ?? null;
  if (url) {
    await worker.trigger({
      function_id: "state::set",
      payload: { scope: "links", key: payload.code, value: { url } },
    });
  }
  logger.info("link resolved", { code: payload.code, found: !!url });
  return { url };
});
```

## Add click tracking

Since we have a database now, you can start click tracking. Make a new function
(`link::record_click`) to do that and save it to the database. The next chapter will move this
work onto a queue so that it can run without touching the redirect's logic. Add it below
`link::resolve`:

```typescript src/index.ts
worker.registerFunction(
  "link::record_click",
  async (payload: { code: string; clicked_at: string }) => {
    await worker.trigger({
      function_id: "database::execute",
      payload: {
        db: DB,
        sql: "INSERT INTO clicks (code, clicked_at) VALUES (?, ?)",
        params: [payload.code, payload.clicked_at],
      },
    });
    return { recorded: true };
  },
);
```

### Update `http::redirect` to call `link::record_click`

**Now update `http::redirect` to trigger it directly**, right before returning the redirect:

```typescript src/index.ts {14-18}
worker.registerFunction("http::redirect", async (req) => {
  const code = req.path_params.code;
  const { url } = await worker.trigger<{ code: string }, { url: string | null }>({
    function_id: "link::resolve",
    payload: { code },
  });
  if (!url) {
    return {
      status_code: 404,
      body: { error: "link not found" },
      headers: { "Content-Type": "application/json" },
    };
  }
  // This Trigger is slow because it waits on link::record_click's completion, we'll move its work to a queue soon
  await worker.trigger({
    function_id: "link::record_click",
    payload: { code, clicked_at: new Date().toISOString() },
  });
  return { status_code: 302, headers: { Location: url } };
});
```

<Note>
  The database write for clicks adds latency to every redirect. The next chapter moves it onto a
  durable queue that removes the latency while also adding recovery from database failures.
</Note>

### Try the click tracking

Now let's see the click tracking in action. Save the file, create a link, and simulate clicking it a
few times:

```bash
curl -s -X POST http://127.0.0.1:3111/links \
  -H 'Content-Type: application/json' -d '{"url":"https://iii.dev","code":"iii"}'
for n in $(seq 1 3); do curl -s -o /dev/null http://127.0.0.1:3111/s/iii; done
```

The durable history is now queryable with SQL:

```bash
iii trigger database::query db=primary sql="SELECT COUNT(*) AS clicks FROM clicks WHERE code = 'iii'"
```

```json
{
  "columns": [
    {
      "name": "clicks",
      "type": ""
    }
  ],
  "row_count": 1,
  "rows": [
    {
      "clicks": 3
    }
  ]
}
```

## Conclusion

<Info>
  Did you know that `--help` works with function id's as well? Try running: `iii trigger
  database::query --help` to see what arguments `database::query` accepts.
</Info>

Linkly's links are now durable: the database is the source of truth, `state` keeps lookups fast,
and every redirect appends a timestamped row to the `clicks` table. But that row is written on the
redirect's hot path, so a slow database write slows the redirect. Next, in
[Ch. 4: Make it durable](/tutorials/linkly/durable-execution), you move that write onto a queue so
redirects stay fast.

Files in this skill

  • channels.mdx4.6 KB
  • channels.mdx.skill.md4.6 KB
  • durable-execution.mdx11.7 KB
  • durable-execution.mdx.skill.md11.7 KB
  • foundations.mdx13.5 KB
  • foundations.mdx.skill.md13.5 KB
  • frontend.mdx11.6 KB
  • frontend.mdx.skill.md11.5 KB
  • observability.mdx6.4 KB
  • observability.mdx.skill.md6.4 KB
  • overview.mdx2.1 KB
  • overview.mdx.skill.md2.1 KB
  • persistence.mdx8.4 KB
  • persistence.mdx.skill.md8.4 KB
  • streaming.mdx4.7 KB
  • streaming.mdx.skill.md4.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…