feat: add mobile vault storage boundary

This commit is contained in:
lucaronin
2026-05-04 10:24:05 +02:00
parent b6f227abb2
commit 3ff99adb3d
5 changed files with 154 additions and 4 deletions

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import { createFixtureMobileVaultRepository } from './mobileVaultRepository'
import { createMobileVaultConfig } from './mobileVaultConfig'
import { createFixtureMobileVaultRepository, createStoredMobileVaultRepository } from './mobileVaultRepository'
import { createMemoryMobileVaultStorage } from './mobileVaultStorage'
import type { MobileNoteSource } from './mobileNoteProjection'
const sources: MobileNoteSource[] = [
@@ -30,6 +32,24 @@ describe('mobile vault repository', () => {
await expect(repository.readNote('missing')).resolves.toBeNull()
})
it('projects notes from app-local markdown storage', async () => {
const repository = createStoredMobileVaultRepository({
storage: createMemoryMobileVaultStorage([
{ path: 'inbox/workflow.md', content: '# Workflow\n\nStored markdown body.' },
{ path: 'release.md', content: '# Release\n\nRelease body.' },
]),
vault: createVault(),
})
const notes = await repository.listNotes()
expect(notes.map((note) => note.id)).toEqual(['inbox/workflow', 'release'])
await expect(repository.readNote('release')).resolves.toMatchObject({
id: 'release',
title: 'Release',
})
})
})
function createSource(id: string, title: string): MobileNoteSource {
@@ -44,3 +64,12 @@ function createSource(id: string, title: string): MobileNoteSource {
content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody text for ${title}.`,
}
}
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -1,4 +1,6 @@
import { projectMobileNotes, type MobileNote, type MobileNoteSource } from './mobileNoteProjection'
import { projectMobileNote, projectMobileNotes, type MobileNote, type MobileNoteSource } from './mobileNoteProjection'
import type { MobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultStorage'
export type MobileVaultRepository = {
listNotes: () => Promise<MobileNote[]>
@@ -13,3 +15,48 @@ export function createFixtureMobileVaultRepository(sources: MobileNoteSource[]):
readNote: (id) => Promise.resolve(notes.find((note) => note.id === id) ?? null),
}
}
export function createStoredMobileVaultRepository({
storage,
vault,
}: {
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}): MobileVaultRepository {
return {
listNotes: async () => projectMobileNotes((await storage.listMarkdownFiles(vault)).map(fileToSource)),
readNote: async (id) => {
const file = await findFileById({ id, storage, vault })
return file ? projectMobileNote(fileToSource(file)) : null
},
}
}
async function findFileById({
id,
storage,
vault,
}: {
id: string
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
return (await storage.listMarkdownFiles(vault)).find((file) => fileId(file.path) === id) ?? null
}
function fileToSource(file: MobileVaultFile): MobileNoteSource {
return {
id: fileId(file.path),
type: 'Note',
icon: 'file-text',
date: '',
modified: '',
filename: file.path,
content: file.content,
tags: [],
}
}
function fileId(path: string) {
return path.replace(/\.md$/, '')
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { createMobileVaultConfig } from './mobileVaultConfig'
import { createMemoryMobileVaultStorage } from './mobileVaultStorage'
const vault = createVault()
describe('mobile vault storage', () => {
it('lists only markdown files in stable path order', async () => {
const storage = createMemoryMobileVaultStorage([
{ path: 'zeta.md', content: '# Zeta' },
{ path: 'asset.png', content: 'binary' },
{ path: 'alpha.md', content: '# Alpha' },
])
await expect(storage.listMarkdownFiles(vault)).resolves.toEqual([
{ path: 'alpha.md', content: '# Alpha' },
{ path: 'zeta.md', content: '# Zeta' },
])
})
it('reads and writes markdown file content by vault path', async () => {
const storage = createMemoryMobileVaultStorage([{ path: 'inbox.md', content: '# Inbox' }])
await storage.writeMarkdownFile(vault, 'inbox.md', '# Updated Inbox')
await expect(storage.readMarkdownFile(vault, 'inbox.md')).resolves.toBe('# Updated Inbox')
await expect(storage.readMarkdownFile(vault, 'missing.md')).resolves.toBeNull()
})
})
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -0,0 +1,31 @@
import type { MobileVaultConfig } from './mobileVaultConfig'
export type MobileVaultFile = {
path: string
content: string
}
export type MobileVaultStorageDriver = {
listMarkdownFiles: (vault: MobileVaultConfig) => Promise<MobileVaultFile[]>
readMarkdownFile: (vault: MobileVaultConfig, path: string) => Promise<string | null>
writeMarkdownFile: (vault: MobileVaultConfig, path: string, content: string) => Promise<void>
}
export function createMemoryMobileVaultStorage(files: MobileVaultFile[]): MobileVaultStorageDriver {
const fileByPath = new Map(files.map((file) => [file.path, file.content]))
return {
listMarkdownFiles: () => Promise.resolve(markdownFiles(fileByPath)),
readMarkdownFile: (_vault, path) => Promise.resolve(fileByPath.get(path) ?? null),
writeMarkdownFile: async (_vault, path, content) => {
fileByPath.set(path, content)
},
}
}
function markdownFiles(fileByPath: Map<string, string>) {
return [...fileByPath.entries()]
.filter(([path]) => path.endsWith('.md'))
.map(([path, content]) => ({ path, content }))
.sort((left, right) => left.path.localeCompare(right.path))
}

View File

@@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Branch: `codex/mobile`
- Active phase: Phase 2 - Mobile Shell
- Active slice: Add supported TenTap Markdown serializer
- Active slice: Add mobile vault storage driver boundary
- Push policy: commit locally; do not push unless explicitly requested
- Validation target: iPad/iOS simulator first
@@ -50,6 +50,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Created [ADR-0110](./adr/0110-tentap-mobile-editor-spike.md) for the TenTap mobile editor spike and its acceptance gates.
- Added a TenTap draft callback boundary that captures editor HTML but explicitly marks it non-persistable until Markdown serialization exists, preventing HTML from becoming canonical vault content by accident.
- Added the first supported TenTap HTML-to-Markdown serializer for H1, paragraph, and unordered-list output; unsupported HTML remains blocked from persistence.
- Added a mobile vault storage driver contract plus memory implementation, and connected the mobile vault repository to app-local markdown files behind that storage interface.
## Next Action
@@ -57,7 +58,7 @@ Continue Phase 2 with the next mobile shell slice:
1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay.
2. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists.
3. Add the first native storage adapter around the vault config/repository contracts after the editor spike confirms the app shape.
3. Implement the Expo FileSystem-backed storage driver behind the new mobile vault storage contract.
## Verification Log
@@ -148,6 +149,10 @@ Continue Phase 2 with the next mobile shell slice:
- `pnpm --filter @tolaria/mobile typecheck` passed after supported Markdown serialization.
- CodeScene after supported Markdown serialization: `apps/mobile/src/mobileEditorDraft.ts`, `apps/mobile/src/mobileEditorDraft.test.ts`, `apps/mobile/src/mobileEditorDocument.ts`, and `apps/mobile/src/MobileEditorAdapter.tsx` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after supported Markdown serialization.
- `pnpm --filter @tolaria/mobile test -- src/mobileVaultStorage.test.ts src/mobileVaultRepository.test.ts` passed after storage boundary extraction: 10 files / 35 tests.
- `pnpm --filter @tolaria/mobile typecheck` passed after storage boundary extraction.
- CodeScene after storage boundary extraction: `apps/mobile/src/mobileVaultStorage.ts`, `apps/mobile/src/mobileVaultStorage.test.ts`, `apps/mobile/src/mobileVaultRepository.ts`, and `apps/mobile/src/mobileVaultRepository.test.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after storage boundary extraction.
## Risks / Watch Items