notes: private Obsidian vault tracker at /trackers/notes
All checks were successful
Deploy production / deploy (push) Successful in 1m19s

Vault files stay the source of truth (bind-mounted read-write at /notes):
atomic writes chowned to the vault owner, hash-checked saves (409 on a
newer file), deletes to .trash. notes.* is a full-text/link index kept
current by a recursive watcher plus a 60s rescan; live updates over the
owner-only /ws/notes channel (paths and hashes only). Wikilinks, embeds,
backlinks, tags, callouts, image paste. Owner-only everywhere (404).
This commit is contained in:
2026-10-26 01:27:41 +00:00
parent 6654c0990a
commit e33afe5387
28 changed files with 44610 additions and 3 deletions

View File

@@ -32,3 +32,4 @@ export * from './ai';
export * from './movies';
export * from './worldtodo';
export * from './osu-goals';
export * from './notes';

View File

@@ -0,0 +1,70 @@
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
customType,
index,
integer,
jsonb,
primaryKey,
text
} from 'drizzle-orm/pg-core';
import { notesSchema } from './schemas';
import { timestamptz } from './helpers';
// Notes tracker (/trackers/notes, owner-only). The Obsidian vault (markdown files on the host,
// Syncthing-synced, bind-mounted at NOTES_ROOT) is the source of truth; these tables are only
// a search and link index that src/lib/server/notes/ rebuilds from the files (file watcher plus a
// periodic rescan). Dropping and rebuilding them loses nothing.
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
export const notesFiles = notesSchema.table(
'files',
{
/** Vault-relative posix path, e.g. "Cars/Toyota/Supra.md". */
path: text('path').primaryKey(),
/** note (.md), attachment (images, pdf, canvas…) or conflict (*.sync-conflict-*, never indexed as a note). */
kind: text('kind').notNull(),
folder: text('folder').notNull(),
title: text('title').notNull(),
ext: text('ext').notNull(),
size: bigint('size', { mode: 'number' }).notNull(),
mtime: timestamptz('mtime').notNull(),
/** sha256 of the file bytes; the editor's save-conflict check compares against it. */
hash: text('hash').notNull(),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
frontmatter: jsonb('frontmatter').$type<Record<string, unknown>>(),
/** Note text without frontmatter (notes only), for snippets. */
body: text('body'),
search: tsvector('search'),
indexedAt: timestamptz('indexed_at').notNull().defaultNow()
},
(t) => [
index('files_search_idx').using('gin', t.search),
index('files_tags_idx').using('gin', t.tags),
index('files_folder_idx').on(t.folder),
index('files_kind_idx').on(t.kind)
]
);
export const notesLinks = notesSchema.table(
'links',
{
srcPath: text('src_path')
.notNull()
.references(() => notesFiles.path, { onDelete: 'cascade', onUpdate: 'cascade' }),
seq: integer('seq').notNull(),
/** Target as written ("Folder/Note", "image.png"), without #heading or |alias. */
target: text('target').notNull(),
/** Lowercased last segment without .md; backlinks match on it, then resolve exactly. */
targetKey: text('target_key').notNull(),
embed: boolean('embed').notNull().default(false)
},
(t) => [
primaryKey({ columns: [t.srcPath, t.seq] }),
index('links_target_key_idx').on(t.targetKey)
]
);

View File

@@ -28,3 +28,4 @@ export const statsSchema = pgSchema('stats');
export const aiSchema = pgSchema('ai');
export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes');