vaultmd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Kalinichenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # vaultmd
2
+
3
+ > A headless markdown-vault data layer for [Bun](https://bun.sh) — CRUD over `.md` notes plus a derived SQLite index for collection queries, backlinks, and full-text search. No Obsidian, no Electron, no plugin.
4
+
5
+ [![npm](https://img.shields.io/badge/npm-vaultmd-cb3837?logo=npm)](https://www.npmjs.com/package/vaultmd)
6
+ [![runtime: Bun](https://img.shields.io/badge/runtime-Bun%20%E2%89%A5%201.1-f9f1e1?logo=bun)](https://bun.sh)
7
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
+ [![status: published](https://img.shields.io/badge/status-published-brightgreen.svg)](#status)
9
+
10
+ `vaultmd` is an npm package that gives your Bun app a programmatic data layer
11
+ over a folder of markdown notes. Your `.md` files on disk stay the **single
12
+ source of truth**; vaultmd maintains a rebuildable `bun:sqlite` index alongside
13
+ them so you can query notes by tag or frontmatter, walk backlinks, and run
14
+ keyword search — all without an editor, sync engine, or background daemon.
15
+
16
+ It's the engine, not the app: generic vault mechanics only. Personas, domain
17
+ schemas, and sync logic live in whatever you build on top.
18
+
19
+ ## Status
20
+
21
+ Released (`0.1.0`) — the first published version is live on npm. The public API
22
+ is frozen and tested, and the package ships as a bundled `dist/` (ESM + types).
23
+ Being `0.x`, the surface may still evolve before `1.0`; see
24
+ [CHANGELOG.md](./CHANGELOG.md) for what changed.
25
+
26
+ ## Features
27
+
28
+ - **CRUD over markdown** — create, read, update, delete `.md` notes with flat
29
+ YAML frontmatter.
30
+ - **Derived SQLite index** — a rebuildable cache, never the source of truth.
31
+ Delete it and it rebuilds from disk.
32
+ - **Collection queries** — filter notes by tag, frontmatter field, or folder;
33
+ order and paginate.
34
+ - **Backlinks & outbound links** — `[[wikilink]]` or relative-link resolution.
35
+ - **Full-text search** — keyword search over note bodies (SQLite FTS5) with
36
+ highlighted snippets.
37
+ - **Write-through indexing** — every mutation updates the index inside the same
38
+ per-file lock as the file write; the two never drift.
39
+ - **Concurrency-safe** — in-process mutex plus optional cross-process lockfiles
40
+ guard concurrent writers.
41
+ - **Scoped access** — per-instance read/write path allowlists make it safe to
42
+ hand different parts of the vault to different consumers.
43
+ - **TypeScript-first** — full types, a small frozen public surface, and lower
44
+ level primitives exported for advanced use.
45
+
46
+ ## Requirements
47
+
48
+ - **[Bun](https://bun.sh) ≥ 1.1.0.** vaultmd uses `bun:sqlite`, `Bun.file`, and
49
+ other Bun built-ins — it does **not** run under Node.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ bun add vaultmd
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```ts
60
+ import { createVault } from 'vaultmd';
61
+
62
+ const vault = await createVault({
63
+ root: '/path/to/vault',
64
+ // Read everything, but only write under Notes/.
65
+ prefixes: { read: [''], write: ['Notes/'] },
66
+ // The index db lives in a DATA dir, NOT inside the synced vault.
67
+ indexPath: './data/vault-index.db',
68
+ });
69
+
70
+ // Create a note with frontmatter + body.
71
+ await vault.notes.createNote('Notes/today.md', {
72
+ frontmatter: { tags: ['project', 'daily'], status: 'open' },
73
+ body: '# Today\n\nSee [[roadmap]] for context.\n',
74
+ });
75
+
76
+ // Query the collection.
77
+ const open = vault.query.queryNotes({
78
+ tag: 'project',
79
+ where: { status: 'open' },
80
+ orderBy: { field: 'mtime_ms', dir: 'desc' },
81
+ limit: 20,
82
+ });
83
+
84
+ // Walk the link graph.
85
+ const incoming = vault.query.backlinks('Notes/roadmap.md');
86
+
87
+ // Full-text search.
88
+ const hits = vault.query.searchText('context');
89
+
90
+ // Append to a note (atomic, write-through indexed).
91
+ await vault.notes.updateNote('Notes/today.md', { append: '\n- shipped readme' });
92
+
93
+ vault.close();
94
+ ```
95
+
96
+ ## Concepts
97
+
98
+ **Files are the source of truth; the index is a cache.** Every note is a plain
99
+ `.md` file you can edit by hand, sync with git or Dropbox, or open in any editor.
100
+ The SQLite index is derived from those files and can be rebuilt at any time
101
+ (`vault.rebuild()`), so it never has to be backed up or trusted over disk.
102
+
103
+ **Read/write scopes.** `prefixes.read` and `prefixes.write` are path-prefix
104
+ allowlists. An empty string (`''`) means "the whole vault". Queries only ever
105
+ return notes the instance is allowed to read; writes are rejected outside the
106
+ write scope. This is the security chokepoint — all path canonicalization and
107
+ containment checks live behind it.
108
+
109
+ **Write-through indexing.** `createNote`, `updateNote`, `editFrontmatter`, and
110
+ `deleteNote` update the index *inside the same per-file lock* as the file write.
111
+ The file and its index row are never updated in separate transactions, so a
112
+ crash can't leave them disagreeing.
113
+
114
+ **Lazy reconcile.** Reads stay synchronous. The first read (and the first after
115
+ each TTL window) fires a single background sweep that picks up any out-of-band
116
+ edits — files you changed in your editor while the process was running. The
117
+ result is visible to the *next* read; a failed sweep never breaks a read.
118
+
119
+ **Index location.** The `.db` file and its `-wal` / `-shm` sidecars must live in
120
+ a data directory **outside** the synced vault, and stay gitignored (`*.db*`).
121
+ Never let the cache get synced as if it were content.
122
+
123
+ ## API
124
+
125
+ The only public entry point is `createVault`. Everything below hangs off the
126
+ `Vault` it returns.
127
+
128
+ ### `createVault(config): Promise<Vault>`
129
+
130
+ | Option | Type | Default | Description |
131
+ | ------------------------ | -------------------------------------- | ------------ | --------------------------------------------------------------------------- |
132
+ | `root` | `string` | *(required)* | Absolute path to the vault directory. |
133
+ | `prefixes` | `{ read: string[]; write: string[] }` | *(required)* | Read/write path-prefix allowlists (`''` = whole vault). |
134
+ | `indexPath` | `string` | *(required)* | Where the SQLite index lives. Keep it **out** of the vault. |
135
+ | `caseSensitive` | `boolean` | *(auto)* | Override filesystem case sensitivity detection. |
136
+ | `ignore` | `string[]` | `[]` | Glob patterns to exclude from indexing. |
137
+ | `linkResolution` | `'wikilink' \| 'relative'` | `'wikilink'` | How links are extracted and resolved. |
138
+ | `lazyReconcile` | `boolean` | `true` | Fire background reconcile sweeps on read. |
139
+ | `reconcileTtlMs` | `number` | `2000` | Minimum gap between lazy sweeps. |
140
+ | `sqliteBusyTimeoutMs` | `number` | `5000` | SQLite busy timeout / cross-process lock wait. |
141
+ | `crossProcessWriterLock` | `boolean` | `true` | Guard writes with cross-process lockfiles. |
142
+ | `onCommit` | `(e: CommitEvent) => void \| Promise` | — | Hook fired after each committed mutation (e.g. to mirror changes upstream). |
143
+
144
+ ### `vault.notes`
145
+
146
+ ```ts
147
+ // Read a note; pass { withLinks: true } to include outbound + backlinks.
148
+ readNote(path, opts?: { withLinks?: boolean }): Promise<ReadNoteResult>
149
+
150
+ // Create a note. Throws ALREADY_EXISTS rather than clobbering.
151
+ createNote(path, input: { frontmatter?: Record<string, unknown>; body: string }): Promise<void>
152
+
153
+ // Mutate body: append text, or replace an exact, unambiguous match.
154
+ updateNote(path, op: { append: string } | { editByMatch: { old: string; new: string } }): Promise<void>
155
+
156
+ // Edit flat frontmatter via a mutator callback. Returns 'edited' | 'unchanged' | 'unverifiable'.
157
+ editFrontmatter(path, mutate: (fm: Record<string, unknown>) => void): Promise<EditOutcome>
158
+
159
+ // Delete a note. Returns whether a file was actually removed.
160
+ deleteNote(path): Promise<boolean>
161
+ ```
162
+
163
+ `ReadNoteResult` is `{ frontmatter, tags, body, valid, outbound?, backlinks? }`,
164
+ where `valid` is `'flat' | 'present-but-invalid' | 'none'`.
165
+
166
+ ### `vault.query`
167
+
168
+ ```ts
169
+ // Filter the collection. Returns NoteHit[] = { path, title, frontmatter, tags }[].
170
+ queryNotes(opts?: {
171
+ tag?: string;
172
+ where?: Record<string, string | number | boolean>; // frontmatter equality
173
+ folder?: string;
174
+ orderBy?: { field: 'mtime_ms' | 'path' | 'title'; dir: 'asc' | 'desc' };
175
+ limit?: number;
176
+ offset?: number;
177
+ }): NoteHit[]
178
+
179
+ // Notes linking TO this path. Returns { from: string }[].
180
+ backlinks(path, opts?: { limit?: number; offset?: number }): Backlink[]
181
+
182
+ // Links FROM this path. Returns { target, resolved }[] (resolved is null if dangling).
183
+ outboundLinks(path, opts?: { limit?: number; offset?: number }): OutboundLink[]
184
+
185
+ // Full-text keyword search over bodies. Returns { path, title, snippet? }[].
186
+ searchText(q, opts?: { tag?: string; folder?: string; limit?: number; offset?: number }): SearchHit[]
187
+ ```
188
+
189
+ ### Lifecycle
190
+
191
+ ```ts
192
+ vault.reconcile(): Promise<void> // full sweep now (vs. lazy)
193
+ vault.reconcilePaths(rels: string[]): Promise<void> // reconcile specific paths
194
+ vault.rebuild(): Promise<void> // drop & rebuild the index from disk
195
+ vault.close(): void // close the db handle
196
+ ```
197
+
198
+ ### Lower-level primitives
199
+
200
+ For advanced use, the package also exports the building blocks `createVault`
201
+ assembles — the IO chokepoint, atomic locked-file transforms, and the pure
202
+ frontmatter / link parsers:
203
+
204
+ ```ts
205
+ import {
206
+ createVaultIo, // path → safe-IO security layer
207
+ withFileTransform, // atomic compare-and-swap file edit
208
+ withFileDelete, // atomic delete with commit hook
209
+ parseFrontmatter, // pure flat-YAML frontmatter parser
210
+ editFrontmatter, // pure frontmatter editor
211
+ isFlatFrontmatter,
212
+ deriveTags,
213
+ extractLinks, // pull wikilinks / relative links from text
214
+ storedLinksFor,
215
+ } from 'vaultmd';
216
+ ```
217
+
218
+ ### Error handling
219
+
220
+ Every failure throws an `MdVaultError` carrying a stable `code`. Catch and
221
+ switch on `err.code`, never on the message:
222
+
223
+ ```ts
224
+ import { MdVaultError } from 'vaultmd';
225
+
226
+ try {
227
+ await vault.notes.createNote('Notes/today.md', { body: '...' });
228
+ } catch (err) {
229
+ if (err instanceof MdVaultError && err.code === 'ALREADY_EXISTS') {
230
+ // handle the clash
231
+ } else {
232
+ throw err;
233
+ }
234
+ }
235
+ ```
236
+
237
+ Codes: `ALLOWLIST_VIOLATION`, `NOT_MARKDOWN`, `NOT_FOUND`, `ALREADY_EXISTS`,
238
+ `NO_MATCH`, `AMBIGUOUS_MATCH`, `MTIME_CONFLICT`, `REFUSE_CREATE`,
239
+ `FRONTMATTER_INVALID`, `VALIDATION_ERROR`, `COMMIT_FAILED`, `INDEX_UNAVAILABLE`.
240
+
241
+ ## Development
242
+
243
+ ```bash
244
+ bun install
245
+ bun test # full suite
246
+ bun run check # biome check . && tsc --noEmit — the authoritative gate
247
+ bun run format # biome format --write .
248
+ ```
249
+
250
+ Run `bun run check` before sending a change; it's green/red, not advisory.
251
+
252
+ ## License
253
+
254
+ [MIT](./LICENSE) © Ivan Kalinichenko
@@ -0,0 +1,253 @@
1
+ import { Database } from 'bun:sqlite';
2
+
3
+ type MdVaultCode = 'ALLOWLIST_VIOLATION' | 'NOT_MARKDOWN' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'NO_MATCH' | 'AMBIGUOUS_MATCH' | 'MTIME_CONFLICT' | 'REFUSE_CREATE' | 'FRONTMATTER_INVALID' | 'VALIDATION_ERROR' | 'COMMIT_FAILED' | 'INDEX_UNAVAILABLE';
4
+ declare class MdVaultError extends Error {
5
+ readonly code: MdVaultCode;
6
+ constructor(code: MdVaultCode, message: string, options?: {
7
+ cause?: unknown;
8
+ });
9
+ }
10
+
11
+ type EditOutcome = 'edited' | 'unchanged' | 'unverifiable';
12
+
13
+ declare function editFrontmatter(content: string, mutate: (fm: Record<string, unknown>) => void): {
14
+ content: string;
15
+ outcome: EditOutcome;
16
+ };
17
+
18
+ type FrontmatterValidity = 'flat' | 'present-but-invalid' | 'none';
19
+
20
+ type ParsedFrontmatter = {
21
+ frontmatter: Record<string, unknown>;
22
+ tags: string[];
23
+ body: string;
24
+ valid: FrontmatterValidity;
25
+ };
26
+
27
+ declare function parseFrontmatter(content: string): ParsedFrontmatter;
28
+
29
+ declare function deriveTags(frontmatter: Record<string, unknown>): string[];
30
+
31
+ declare function isFlatFrontmatter(fm: Record<string, unknown>): boolean;
32
+
33
+ type Sig = {
34
+ mtimeMs: number;
35
+ size: number;
36
+ };
37
+
38
+ type ExtractedLinks = {
39
+ wikilinks: string[];
40
+ embeds: string[];
41
+ mdLinks: string[];
42
+ };
43
+
44
+ declare function extractLinks(content: string): ExtractedLinks;
45
+
46
+ type LinkResolution = 'wikilink' | 'relative';
47
+
48
+ type StoredLink = {
49
+ target: string;
50
+ base: string | null;
51
+ kind: 'wikilink' | 'embed' | 'mdlink';
52
+ };
53
+
54
+ declare function storedLinksFor(content: string, srcRel: string, mode: LinkResolution): StoredLink[];
55
+
56
+ type CommitEvent = {
57
+ op: 'create' | 'update';
58
+ path: string;
59
+ content: string;
60
+ } | {
61
+ op: 'delete';
62
+ path: string;
63
+ };
64
+
65
+ type CrossLock = {
66
+ lockDir: string;
67
+ busyTimeoutMs: number;
68
+ };
69
+
70
+ /**
71
+ * @param lockKey Canonical/case-folded serialization key — pass `VaultIo.toKey(rel)`.
72
+ * @param relForCommit Display path written to `CommitEvent.path` — pass `VaultIo.toVaultRelative(rel)`.
73
+ */
74
+ declare function withFileDelete(fullPath: string, lockKey: string, relForCommit: string, opts?: {
75
+ onCommit?: (e: CommitEvent) => void | Promise<void>;
76
+ cross?: CrossLock | false;
77
+ }): Promise<{
78
+ deleted: boolean;
79
+ }>;
80
+
81
+ type TransformOpts = {
82
+ allowCreate?: boolean;
83
+ onCommit?: (e: CommitEvent) => void | Promise<void>;
84
+ maxRetries?: number;
85
+ cross?: CrossLock | false;
86
+ };
87
+
88
+ type TransformResult = {
89
+ content: string | null;
90
+ outcome: 'created' | 'updated' | 'unchanged';
91
+ };
92
+
93
+ /**
94
+ * @param lockKey Canonical/case-folded serialization key — pass `VaultIo.toKey(rel)`.
95
+ * @param relForCommit Display path written to `CommitEvent.path` — pass `VaultIo.toVaultRelative(rel)`.
96
+ */
97
+ declare function withFileTransform(fullPath: string, lockKey: string, relForCommit: string, transform: (current: string | null) => string | null, opts?: TransformOpts): Promise<TransformResult>;
98
+
99
+ type Backlink = {
100
+ from: string;
101
+ };
102
+
103
+ type NoteHit = {
104
+ path: string;
105
+ title: string;
106
+ frontmatter: Record<string, unknown>;
107
+ tags: string[];
108
+ };
109
+
110
+ type OrderField = 'mtime_ms' | 'path' | 'title';
111
+ type QueryOrder = {
112
+ field: OrderField;
113
+ dir: 'asc' | 'desc';
114
+ };
115
+
116
+ type OutboundLink = {
117
+ target: string;
118
+ resolved: string | null;
119
+ };
120
+
121
+ type SearchHit = {
122
+ path: string;
123
+ title: string;
124
+ snippet?: string;
125
+ };
126
+
127
+ type WhereMap = Record<string, string | number | boolean>;
128
+
129
+ type Access = 'read' | 'write';
130
+
131
+ type VaultIo = {
132
+ toVaultRelative(rel: string): string;
133
+ toKey(rel: string): string;
134
+ can(rel: string, access: Access): boolean;
135
+ resolveVaultPath(rel: string, access?: Access): string;
136
+ readVaultFile(rel: string): Promise<{
137
+ content: string;
138
+ sig: Sig;
139
+ } | null>;
140
+ writeVaultFile(rel: string, content: string): Promise<Sig>;
141
+ rewriteIfUnchanged(rel: string, content: string, expected: Sig): Promise<Sig>;
142
+ unlinkIfUnchanged(rel: string, expected: Sig): Promise<boolean>;
143
+ stat(rel: string): Promise<Sig | null>;
144
+ listMarkdown(dir?: string): Promise<string[]>;
145
+ };
146
+
147
+ type VaultPrefixes = {
148
+ read: string[];
149
+ write: string[];
150
+ };
151
+
152
+ type VaultIoConfig = {
153
+ root: string;
154
+ prefixes: VaultPrefixes;
155
+ caseSensitive?: boolean;
156
+ ignore?: string[];
157
+ };
158
+
159
+ declare function createVaultIo(config: VaultIoConfig): VaultIo;
160
+
161
+ type IndexConfig = {
162
+ linkResolution: LinkResolution;
163
+ caseSensitive: boolean;
164
+ ignore: string[];
165
+ };
166
+
167
+ declare function createQuery(db: Database, vaultIo: VaultIo, cfg: IndexConfig): {
168
+ queryNotes: (opts?: {
169
+ tag?: string;
170
+ where?: WhereMap;
171
+ folder?: string;
172
+ orderBy?: QueryOrder;
173
+ limit?: number;
174
+ offset?: number;
175
+ }) => NoteHit[];
176
+ backlinks: (path: string, opts?: {
177
+ limit?: number;
178
+ offset?: number;
179
+ }) => Backlink[];
180
+ outboundLinks: (path: string, opts?: {
181
+ limit?: number;
182
+ offset?: number;
183
+ }) => OutboundLink[];
184
+ searchText: (q: string, opts?: {
185
+ tag?: string;
186
+ folder?: string;
187
+ limit?: number;
188
+ offset?: number;
189
+ }) => SearchHit[];
190
+ };
191
+
192
+ type ReadNoteResult = {
193
+ frontmatter: Record<string, unknown>;
194
+ tags: string[];
195
+ body: string;
196
+ valid: FrontmatterValidity;
197
+ outbound?: OutboundLink[];
198
+ backlinks?: Backlink[];
199
+ };
200
+
201
+ type UpdateOp = {
202
+ editByMatch: {
203
+ old: string;
204
+ new: string;
205
+ };
206
+ } | {
207
+ append: string;
208
+ };
209
+
210
+ type NotesDeps = {
211
+ db: Database;
212
+ vaultIo: VaultIo;
213
+ cfg: IndexConfig;
214
+ query: ReturnType<typeof createQuery>;
215
+ onCommit?: (e: CommitEvent) => void | Promise<void>;
216
+ cross?: CrossLock | false;
217
+ };
218
+ declare function createNotes(deps: NotesDeps): {
219
+ readNote: (path: string, opts?: {
220
+ withLinks?: boolean;
221
+ }) => Promise<ReadNoteResult>;
222
+ createNote: (path: string, input: {
223
+ frontmatter?: Record<string, unknown>;
224
+ body: string;
225
+ }) => Promise<void>;
226
+ updateNote: (path: string, op: UpdateOp) => Promise<void>;
227
+ editFrontmatter: (path: string, mutate: (fm: Record<string, unknown>) => void) => Promise<EditOutcome>;
228
+ deleteNote: (path: string) => Promise<boolean>;
229
+ };
230
+
231
+ type CreateVaultConfig = VaultIoConfig & {
232
+ indexPath: string;
233
+ linkResolution?: LinkResolution;
234
+ lazyReconcile?: boolean;
235
+ reconcileTtlMs?: number;
236
+ sqliteBusyTimeoutMs?: number;
237
+ crossProcessWriterLock?: boolean;
238
+ onCommit?: (e: CommitEvent) => void | Promise<void>;
239
+ };
240
+
241
+ type Vault = {
242
+ io: VaultIo;
243
+ notes: ReturnType<typeof createNotes>;
244
+ query: ReturnType<typeof createQuery>;
245
+ reconcile(): Promise<void>;
246
+ reconcilePaths(rels: string[]): Promise<void>;
247
+ rebuild(): Promise<void>;
248
+ close(): void;
249
+ };
250
+
251
+ declare function createVault(config: CreateVaultConfig): Promise<Vault>;
252
+
253
+ export { type Access, type CommitEvent, type CreateVaultConfig, type CrossLock, type EditOutcome, type ExtractedLinks, type FrontmatterValidity, type LinkResolution, type MdVaultCode, MdVaultError, type NoteHit, type OrderField, type ParsedFrontmatter, type QueryOrder, type ReadNoteResult, type SearchHit, type Sig, type StoredLink, type TransformOpts, type TransformResult, type UpdateOp, type Vault, type VaultIo, type VaultIoConfig, type VaultPrefixes, type WhereMap, createVault, createVaultIo, deriveTags, editFrontmatter, extractLinks, isFlatFrontmatter, parseFrontmatter, storedLinksFor, withFileDelete, withFileTransform };