Notes and folders, where the filesystem is the source of truth and this table is an index of it.
Every write goes to disk first and is then indexed from what actually landed — never from what the caller intended. If the write succeeds and the indexing fails, the note still exists and the next rescan picks it up; if the write fails, nothing is indexed. The disk is never the thing that ends up wrong.
The stale-write guard
Without a file watcher, the real hazard is the web UI overwriting an edit made
in vim (or Obsidian, or a script) since the page was loaded. Before every
write, the file's current hash is compared with the indexed one; if they
differ, the save is refused with {:error, :stale} and the caller is told to
rescan. That is the difference between "eventually consistent" and "silently
loses work".
Access is not checked here
The caller has already resolved a role through
PhoenixKitKnowledgeBase.Vaults.fetch_for/2, and every function is scoped to
a vault — which keeps the permission decision in one place rather than
re-derived in twenty query functions.
Summary
Functions
How many notes a vault holds.
Note counts for several vaults in one query, as %{vault_uuid => count}.
Create a directory on disk and index it, plus any parents it needed.
Create a note: write the file, then index what landed.
Move a directory and everything under it to the trash, and drop its index rows.
Move a note to the vault's trash and drop its index row.
Whether the file backing a note has changed since it was indexed.
Empty the vault's trash.
A note and the vault holding it, from the note uuid alone.
The folder tree as nested %{folder: folder, children: [...]} nodes.
Fetch a folder scoped to its vault, by uuid, or nil.
Fetch a folder by its path inside the vault, or nil.
Fetch a note scoped to its vault, or nil (also for a malformed uuid).
Fetch a note by its path within the vault, or nil.
The first note (by path) whose front matter has key: value, or nil.
See list_notes/2's :property.
Fetch a note by title, matched case- and whitespace-insensitively.
Index one file: upsert its row from the bytes on disk and rebuild its links.
Every indexed folder in a vault, ordered by path so a tree can be built in one pass.
Notes in a vault.
Every tag in a vault with how many notes carry it, most used first.
Every note's uuid, title and folder, for building the sidebar tree.
Everything in the vault's trash, newest first.
Move a note into another folder ("" = the vault root), moving its file.
Delete one trashed item for good.
The note's current content read from disk, bypassing the cached column.
Move or rename a folder, and reindex everything underneath it.
Rename a note — which renames its file, because the filename is the title.
Restore a trashed note to where it came from, and reindex it.
Full-text search over a vault, best match first.
Title-prefix search, for the [[ link picker and the quick switcher.
search/3, with a snippet of each note showing where it matched.
Make the folder index agree with the directories on disk.
How many notes carry each of these normalised titles.
Save new content for a note.
Functions
@spec count_notes(String.t()) :: non_neg_integer()
How many notes a vault holds.
@spec count_notes_by_vault([String.t()]) :: %{ required(String.t()) => non_neg_integer() }
Note counts for several vaults in one query, as %{vault_uuid => count}.
For list pages that show a count per row — one grouped query instead of one
count_notes/1 per card.
@spec create_folder(PhoenixKitKnowledgeBase.Schemas.Vault.t(), String.t(), String.t()) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Folder.t()} | {:error, term()}
Create a directory on disk and index it, plus any parents it needed.
parent_path is "" for the vault root.
@spec create_note( PhoenixKitKnowledgeBase.Schemas.Vault.t(), map(), String.t() | nil, keyword() ) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
Create a note: write the file, then index what landed.
Refuses if a file is already at that path, rather than overwriting a note that the index may not even know about yet.
Options:
unique: true— when the name is taken, number it instead of refusing:Ideas→Ideas 2→Ideas 3… (the note's title is the file name, so read it back from the returned note).
@spec delete_folder( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Folder.t() ) :: {:ok, non_neg_integer()} | {:error, term()}
Move a directory and everything under it to the trash, and drop its index rows.
The notes inside go with it — a folder is its contents — but they go to
.trash/ with the subtree intact, so the whole folder can be restored.
@spec delete_note( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Note.t() ) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
Move a note to the vault's trash and drop its index row.
The file is not unlinked — it goes to .trash/, so an accidental delete is
recoverable (list_trash/1, restore_note/2). The index row goes because a
trashed note is not part of the vault any more: it should not appear in
search, in the sidebar or in the link graph.
Links into it become unresolved rather than disappearing, so the notes that mentioned it keep saying so — and restoring re-resolves them.
@spec disk_state( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Note.t() ) :: :current | :changed | :missing
Whether the file backing a note has changed since it was indexed.
:missing when the file is gone, which is a conflict too — saving would
silently resurrect a note somebody deleted on disk.
@spec empty_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t()) :: :ok | {:error, term()}
Empty the vault's trash.
@spec fetch_with_vault(String.t()) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t(), PhoenixKitKnowledgeBase.Schemas.Vault.t()} | :error
A note and the vault holding it, from the note uuid alone.
For a caller that has only the uuid and no vault in hand — the collaboration store, whose document id is the note uuid, running inside a room process that never saw a LiveView's assigns. One query rather than two, because it runs on every flush.
The folder tree as nested %{folder: folder, children: [...]} nodes.
Rebuilt from relative_path rather than stored: the filesystem already owns
the hierarchy, and a second copy of it in the database is a second thing that
can disagree with the disk.
@spec get_folder(String.t(), String.t() | nil) :: PhoenixKitKnowledgeBase.Schemas.Folder.t() | nil
Fetch a folder scoped to its vault, by uuid, or nil.
@spec get_folder_by_path(String.t(), String.t()) :: PhoenixKitKnowledgeBase.Schemas.Folder.t() | nil
Fetch a folder by its path inside the vault, or nil.
@spec get_note(String.t(), String.t() | nil) :: PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
Fetch a note scoped to its vault, or nil (also for a malformed uuid).
@spec get_note_by_path(String.t(), String.t()) :: PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
Fetch a note by its path within the vault, or nil.
@spec get_note_by_property(String.t(), String.t(), term()) :: PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
The first note (by path) whose front matter has key: value, or nil.
See list_notes/2's :property.
@spec get_note_by_title(String.t(), String.t() | nil) :: PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
Fetch a note by title, matched case- and whitespace-insensitively.
Returns the same note [[Title]] would resolve to from the vault root when
several share a title.
@spec index_entry( PhoenixKitKnowledgeBase.Schemas.Vault.t(), String.t(), map(), keyword() ) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
Index one file: upsert its row from the bytes on disk and rebuild its links.
The single entry point for "make the database agree with this file", used by every write path and by the rescan.
Pass broadcast: false during a bulk reindex; a rescan announces itself once
at the end rather than once per file.
Pass resolver: when indexing several notes in one pass; otherwise each call
builds its own, which is a scan of every note in the vault per note indexed.
Pass existing: note when the file has just moved and the row to update is
therefore not the one at relative_path. Without it a rename looks up the
new path, finds nothing, and inserts a second row — leaving the note listed
twice, the old entry pointing at a file that is no longer there, and every
backlink still attached to the stale one. Storage.move_note/3 refuses to
overwrite an existing file, so the row named here cannot collide with a real
note at the target.
@spec list_folders(String.t(), keyword()) :: [ PhoenixKitKnowledgeBase.Schemas.Folder.t() ]
Every indexed folder in a vault, ordered by path so a tree can be built in one pass.
Options:
:parent— only the folders directly inside this one ("": the vault root's own). For a listing of one folder, which would otherwise read the whole vault's tree to show a level of it.
@spec list_notes(String.t(), keyword()) :: [PhoenixKitKnowledgeBase.Schemas.Note.t()]
Notes in a vault.
Options:
:folder_path—""for the vault root,:any(default) for everything:under— a folder and everything below it (""ornil: no limit):tag— only notes carrying this tag:order—:title(default) or:updated:limit:content—falseleaves each note's body unloaded (it reads""). For listings and link resolution, which need titles, paths, tags and dates and would otherwise read every note's whole text — a notes panel does that on every poll.:property—{key, value}: only notes whose front matter haskey: valueat the top level. The value is compared as text (YAML7matches"7"), so a host keeping an id in its notes' properties finds them without querying this module's tables.:has_property— a key, or a list of keys: only notes whose front matter has at least one of them.
@spec list_tags(String.t()) :: [{String.t(), pos_integer()}]
Every tag in a vault with how many notes carry it, most used first.
Aggregated in Elixir rather than with unnest: a vault's tag arrays are small
and this keeps the query a plain scoped select. If a vault ever outgrows
loading one array per note, this is the function to move into SQL — the
callers do not change.
@spec list_titles(String.t()) :: [ %{uuid: String.t(), title: String.t(), folder_path: String.t()} ]
Every note's uuid, title and folder, for building the sidebar tree.
Three columns rather than whole rows: the tree needs a label and something to
drag, and content is the entire markdown of every note in the vault. This
runs on every note click, folder click, tag click, drag and peer save.
@spec list_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t(), keyword()) :: [map()]
Everything in the vault's trash, newest first.
frontmatter: true adds each entry's parsed front matter (%{} for a
file without any, or one that is not markdown), so a host that binds notes
by a property — an id: — can find its trashed copy without reading the
trash itself.
@spec move_note( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Note.t(), String.t(), keyword() ) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
Move a note into another folder ("" = the vault root), moving its file.
Options:
title:— rename it in the same step: one file move, so there is no moment where the note sits renamed in the old folder (or moved under its old name, colliding there).unique: true— number the name when it is taken in the target folder (Ideas 2) instead of refusing with:target_exists.
@spec purge_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t(), map()) :: :ok | {:error, term()}
Delete one trashed item for good.
@spec read_from_disk( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Note.t() ) :: {:ok, String.t()} | {:error, term()}
The note's current content read from disk, bypassing the cached column.
@spec relocate_folder( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Folder.t(), String.t() ) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Folder.t()} | {:error, term()}
Move or rename a folder, and reindex everything underneath it.
One function, because on a filesystem it is one operation: renaming
research and moving it into 2026/ are both a directory rename.
The reindex is the part worth reading. Every note under the folder gets a new
relative_path, and each is re-indexed naming the row to update — the
same existing: that move_and_reindex/4 needs, and for the same reason: a
lookup by the new path finds nothing, inserts a second row, and strands every
backlink on the old one. Moving a folder of thirty notes would do that thirty
times.
Then the link graph. Path-qualified links ([[research/Ideas]]) name a path
that no longer exists, and the same link written against the new path starts
resolving — so every key the subtree answered to, before and after, is
re-resolved together.
Folder rows are reconciled against the disk rather than rewritten from the old prefix, so empty subdirectories move with the rest instead of vanishing from the sidebar until the next rescan.
@spec rename_note( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Note.t(), String.t(), keyword() ) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
Rename a note — which renames its file, because the filename is the title.
Links to the old name stop resolving and links waiting for the new one start, both handled here.
unique: true numbers the new name when it is taken in the folder
(Ideas 2), instead of refusing with :target_exists.
@spec restore_note(PhoenixKitKnowledgeBase.Schemas.Vault.t(), map(), keyword()) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
Restore a trashed note to where it came from, and reindex it.
Every link that was waiting for its title resolves again, which is the whole point of un-deleting rather than re-typing.
Refuses with :target_exists when a note has taken the old place since;
with unique: true it comes back numbered beside it instead (Ideas 2).
@spec search(String.t(), String.t(), keyword()) :: [ PhoenixKitKnowledgeBase.Schemas.Note.t() ]
Full-text search over a vault, best match first.
Runs against the cached content column via a generated tsvector, with the
title weighted above the body so an exact-title hit outranks a passing
mention. websearch_to_tsquery parses the query the way a search box user
expects (quoted phrases, -exclusions) and, unlike to_tsquery, cannot be
made to raise by punctuation someone typed.
The 'english' config is written literally in both fragments and matches the
one the migration's generated column uses — a mismatch would search a vector
built with different stemming rules. It cannot be a bound parameter: that
argument is a regconfig, so Postgres types the placeholder as an OID and
Postgrex refuses to encode a string into it.
The search_vector reference is unqualified SQL because the column is
deliberately not mapped in Note; there is exactly one table here, so it is
unambiguous.
@spec search_titles(String.t(), String.t(), keyword()) :: [ PhoenixKitKnowledgeBase.Schemas.Note.t() ]
Title-prefix search, for the [[ link picker and the quick switcher.
Separate from search/3 on purpose: full-text matches whole lexemes, so
typing "kno" finds nothing until the word is complete, while picking a link
has to respond to a half-typed word.
@spec search_with_snippets(String.t(), String.t(), keyword()) :: [ %{ note: PhoenixKitKnowledgeBase.Schemas.Note.t(), snippet: [{:text | :match, String.t()}] } ]
search/3, with a snippet of each note showing where it matched.
Returns [%{note: note, snippet: segments}], best match first, where
segments is a list of {:text, binary} and {:match, binary} pieces of
the note's body around the matched words (Postgres ts_headline, with the
front matter left out). Segments rather than HTML, so the host renders and
escapes them its own way: nothing from a note's text reaches a page
unescaped through this.
Options: :limit (default 50), :words (the snippet's length in words,
default 18, kept within 3..60).
@spec sync_folders(PhoenixKitKnowledgeBase.Schemas.Vault.t()) :: {:ok, {non_neg_integer(), non_neg_integer()}} | {:error, term()}
Make the folder index agree with the directories on disk.
Used after a folder move and by the rescan, which is the only other thing that can make the two disagree.
{:error, reason} when the directory cannot be listed — a vault that lost
read permission or was unmounted. That has to reach the rescan report:
answering {0, 0} there would report "everything already up to date" for a
vault nothing can read.
@spec title_key_counts(String.t(), [String.t()]) :: %{ required(String.t()) => pos_integer() }
How many notes carry each of these normalised titles.
Used to decide whether a suggested [[link]] needs to be path-qualified: a
title only one note answers to can be inserted bare, and one that several
share should be inserted as a path so the link is not left to the tie-break.
@spec update_content( PhoenixKitKnowledgeBase.Schemas.Vault.t(), PhoenixKitKnowledgeBase.Schemas.Note.t(), String.t(), keyword() ) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
Save new content for a note.
Guarded against clobbering an external edit — see the moduledoc. Pass
force: true to overwrite deliberately, which is what a "save anyway" button
would do.