Personal notebook
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), andscan()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 have | Opt in via |
|---|---|
| Record version history | withHistory() from @noy-db/hub/history |
Aggregates / groupBy | withReduce() from @noy-db/hub/reduce |
| File / blob attachments | withBlobs() from @noy-db/hub/blobs |
| Multi-locale records | withI18n() from @noy-db/hub/i18n |
| Multi-user grant / revoke | withTeam() from @noy-db/hub/team — the built-in single-owner keyring is enough for solo apps |
| Replication to a remote peer | withSync() 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
- Vault & Collection model
- Enclave
- Permissions & Keyring
- Service catalog — every opt-in service, one page each