Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsCommunityBlog
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

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,691 stars
0 votes
0 copies
0 views
Added 9/20/2026
developmenttypescriptgobashsqldatabase

Works with

cli

Security Analysis

A100/100

Scanned 9/20/2026

Install to Claude Code

$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 automatically 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.

Download Zip
Files
channels.mdx
<!-- 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.

Attribution

iii-hqiii-hq
View sourceMore from iii-hq →
SSkills DirectorySkills Directory

Know which skills are safe — weekly.

Best new skills + every skill we flagged as malicious. From the team that scanned 103,619.

Join free

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 (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Know which skills are safe — weekly.

Best new skills + every skill we flagged as malicious. From the team that scanned 103,619.

Join free

Related Skills

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

281612 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2132 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Tanstack Start

Build a full-stack TanStack Start app on Cloudflare Workers from scratch — SSR, file-based routing, server functions, D1+Drizzle, better-auth, Tailwind v4+shadcn/ui. Use whenever the user mentions TanStack Start, asks to scaffold a full-stack Cloudflare app with SSR, wants an SSR dashboard, or asks for a React 19 + Cloudflare Workers app with file-based routing and server functions — even if they don't name TanStack Start specifically. No template repo — Claude generates every file fresh per ...

9881 votes

Pentest

PTES-aligned adversarial security audit for backend, frontend, and mobile applications. Produces a CVSS-scored Hacker Report with verified PoCs and phased remediation.

5491 votes
View all in development →