stable

Personal notebook

The minimalist, core-only encrypted notebook — the smallest possible createNoydb({...}) with no opt-in services.

Audience: single user, single device, local-only — the simplest possible noy-db app: one user's encrypted notes, no server. Bundle: the always-on core only, about 6,500 lines of library code — no opt-in services (no history, blobs, sync, or aggregates).

Verified by: showcases/src/recipe-personal-notebook.recipe.test.ts — the runnable form of this recipe.

noy-db is an encrypted document store: your records are encrypted inside your app's process before they reach any storage backend, so the backend only ever holds ciphertext. This recipe uses nothing but the core — one store adapter, one vault (an encrypted namespace of collections), one typed collection — to build a personal notebook whose contents stay unreadable to anything that can inspect the browser's IndexedDB.

What you get

  • Encrypted document storage in the browser (IndexedDB via @noy-db/to-browser-idb) or on disk (toFile() from @noy-db/to-file)
  • Typed collections, basic queries (where / orderBy / limit), and scan() for streaming reads
  • A single-owner keyring: your secret derives a key-encryption key (KEK, via PBKDF2-SHA256), which wraps the data-encryption keys (DEKs) that encrypt your records. One user, one role (owner), no key ceremony to manage — see Permissions & Keyring
  • Optional schema validation via any Standard Schema library (Zod, Valibot, ArkType, …)

What you don't get (until you opt in)

Everything beyond the core is a service you opt into by passing its factory as an option to createNoydb() (for example historyStrategy: withHistory()):

You don't haveOpt in via
Record version historywithHistory() from @noy-db/hub/history
Aggregates / groupBywithReduce() from @noy-db/hub/reduce
File / blob attachmentswithBlobs() from @noy-db/hub/blobs
Multi-locale recordswithI18n() from @noy-db/hub/i18n
Multi-user grant / revokewithTeam() from @noy-db/hub/team — the built-in single-owner keyring is enough for solo apps
Replication to a remote peerwithSync() from @noy-db/hub/sync

One thing you do get without opting in: joins. query().join('clientId', { as: 'client' }) over a ref()-declared foreign key is part of the always-on core — see joins.

Features you have not opted into fail loudly, with a pointer at the subpath to enable:

await notes.history('note-1')
// → throws — the error message points at withHistory() from '@noy-db/hub/history'

The whole app

import { createNoydb } from '@noy-db/hub'
import { toBrowserIdb } from '@noy-db/to-browser-idb'

interface Note {
  id: string
  title: string
  body: string
  createdAt: string
}

const db = await createNoydb({
  store: toBrowserIdb(),
  user: 'me',
  secret: 'correct-horse-battery-staple', // user-supplied secret
})

const vault = await db.openVault('notebook')
const notes = vault.collection<Note>('notes')

// Write
await notes.put('note-1', {
  id: 'note-1',
  title: 'Groceries',
  body: 'eggs, milk',
  createdAt: '2026-07-06T09:00:00.000Z',
})
await notes.put('note-2', {
  id: 'note-2',
  title: 'Meeting notes',
  body: 'ship the notebook demo',
  createdAt: '2026-07-07T14:30:00.000Z',
})

// Read by id
const one = await notes.get('note-1')
// → { id: 'note-1', title: 'Groceries', body: 'eggs, milk', createdAt: '2026-07-06T09:00:00.000Z' }

// Query — newest first. query() runs synchronously over the
// decrypted in-memory cache, so no await is needed.
const recent = notes
  .query()
  .orderBy('createdAt', 'desc')
  .limit(10)
  .toArray()

// Stream — reads records without loading the whole collection into memory
for await (const n of notes.scan()) {
  console.log(n.title)
}
// → Groceries
// → Meeting notes

// Lifecycle — drops the KEK and DEKs from memory
db.close()

The recent query returns the decrypted, typed records, newest first:

[
  {
    "id": "note-2",
    "title": "Meeting notes",
    "body": "ship the notebook demo",
    "createdAt": "2026-07-07T14:30:00.000Z"
  },
  {
    "id": "note-1",
    "title": "Groceries",
    "body": "eggs, milk",
    "createdAt": "2026-07-06T09:00:00.000Z"
  }
]

That's the entire surface area you need for a personal encrypted notebook.

What the store actually sees

Encryption happens in @noy-db/hub before a record reaches the store, so IndexedDB never holds a readable note — only an envelope: a small JSON wrapper whose _data field is AES-256-GCM ciphertext and whose other fields are non-sensitive metadata (format marker, version counter, timestamp, encryption IV):

{
  "_noydb": 1,
  "_v": 1,
  "_ts": "2026-07-06T09:00:00.412Z",
  "_iv": "…base64…",
  "_data": "…base64 AES-256-GCM ciphertext…"
}

See Enclave for the full key lifecycle.

Schema validation (optional, lightweight)

Attach any Standard Schema validator to the collection. put() then validates before encrypting, and reads validate the decrypted record on the way out — drifted stored data is caught too.

import { z } from 'zod'

const NoteSchema = z.object({
  id: z.string(),
  title: z.string().min(1).max(200),
  body: z.string(),
  createdAt: z.string().datetime(),
})

const notes = vault.collection<z.infer<typeof NoteSchema>>('notes', {
  schema: NoteSchema,
})

await notes.put('bad', { id: 'bad', title: '', body: '', createdAt: 'not-a-date' })
// → throws SchemaValidationError with the validator's field-level issues

When this recipe stops being enough

  • You want to see what a note used to say → upgrade to accounting-app (adds withHistory)
  • You want to attach files → upgrade to accounting-app (adds withBlobs)
  • You want two devices to stay in sync → upgrade to realtime-crdt-app (adds withSync + withCrdt)
  • You want fast queries on a million records → upgrade to analytics-app (adds withIndexing + withReduce)

See also