PhoenixKitKnowledgeBase.Notes (PhoenixKitKnowledgeBase v0.1.0)

Copy Markdown View Source

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.

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.

Functions

count_notes(vault_uuid)

@spec count_notes(String.t()) :: non_neg_integer()

How many notes a vault holds.

count_notes_by_vault(vault_uuids)

@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.

create_folder(vault, parent_path, name)

Create a directory on disk and index it, plus any parents it needed.

parent_path is "" for the vault root.

create_note(vault, attrs, actor_uuid \\ nil, opts \\ [])

@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).

delete_folder(vault, folder)

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.

delete_note(vault, note)

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.

disk_state(vault, note)

@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.

empty_trash(vault)

@spec empty_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t()) :: :ok | {:error, term()}

Empty the vault's trash.

fetch_with_vault(note_uuid)

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.

folder_tree(vault_uuid)

@spec folder_tree(String.t()) :: [map()]

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.

get_folder(vault_uuid, folder_uuid)

@spec get_folder(String.t(), String.t() | nil) ::
  PhoenixKitKnowledgeBase.Schemas.Folder.t() | nil

Fetch a folder scoped to its vault, by uuid, or nil.

get_folder_by_path(vault_uuid, relative_path)

@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.

get_note(vault_uuid, note_uuid)

@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).

get_note_by_path(vault_uuid, relative_path)

@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.

get_note_by_property(vault_uuid, key, value)

@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.

get_note_by_title(vault_uuid, title)

@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.

index_entry(vault, relative_path, stat, opts \\ [])

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.

list_folders(vault_uuid, opts \\ [])

@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.

list_notes(vault_uuid, opts \\ [])

Notes in a vault.

Options:

  • :folder_path — "" for the vault root, :any (default) for everything
  • :under — a folder and everything below it ("" or nil: no limit)
  • :tag — only notes carrying this tag
  • :order — :title (default) or :updated
  • :limit
  • :content — false leaves 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 has key: value at the top level. The value is compared as text (YAML 7 matches "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.

list_tags(vault_uuid)

@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.

list_titles(vault_uuid)

@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.

list_trash(vault, opts \\ [])

@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.

move_note(vault, note, folder_path, opts \\ [])

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.

purge_trash(vault, map)

@spec purge_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t(), map()) ::
  :ok | {:error, term()}

Delete one trashed item for good.

read_from_disk(vault, note)

@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.

relocate_folder(vault, folder, target_path)

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.

rename_note(vault, note, new_title, opts \\ [])

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.

restore_note(vault, entry, opts \\ [])

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).

search(vault_uuid, term, opts \\ [])

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.

search_titles(vault_uuid, term, opts \\ [])

@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.

search_with_snippets(vault_uuid, term, opts \\ [])

@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).

sync_folders(vault)

@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.

title_key_counts(vault_uuid, keys)

@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.

update_content(vault, note, content, opts \\ [])

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.