feat: add mobile git transport boundary

This commit is contained in:
lucaronin
2026-05-05 13:50:04 +02:00
parent fc24575af0
commit 0f33e8958b
10 changed files with 490 additions and 35 deletions

View File

@@ -65,6 +65,7 @@ import type { MobileVaultRuntime } from './mobileVaultRuntime'
import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader'
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
import { useMobileGitSyncFlow } from './useMobileGitSyncFlow'
import { createUnavailableMobileGitTransport } from './mobileGitTransport'
export function MobileApp() {
const { width } = useWindowDimensions()
@@ -72,6 +73,7 @@ export function MobileApp() {
const showsProperties = width >= 1000
const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), [])
const gitCredentialStorage = useMemo(() => createNativeMobileGitCredentialStorage(), [])
const gitTransport = useMemo(() => createUnavailableMobileGitTransport(), [])
const gitHubOAuthClientIdState = useMemo(() => currentMobileGitHubOAuthClientIdState(), [])
const vaultMetadataStorage = useMemo(() => createNativeMobileVaultMetadataStorage(), [])
const [activeVaultMetadata, setActiveVaultMetadata] = useState(defaultMobileVaultMetadata)
@@ -86,6 +88,7 @@ export function MobileApp() {
const gitSyncFlow = useMobileGitSyncFlow({
createGitHubOAuthSession: createNativeMobileGitHubOAuthSessionFromEnvironment,
credentialStorage: gitCredentialStorage,
gitTransport,
vault: activeVaultMetadata,
})
const autosaveQueue = useMemo(
@@ -173,7 +176,7 @@ export function MobileApp() {
onCancelCreateNote={createFlow.cancel}
onChangeCreateNoteTitle={createFlow.setTitle}
onOpenCreateNote={createFlow.open}
onGitSyncAction={gitSyncFlow.authenticate}
onGitSyncAction={gitSyncFlow.runPrimaryAction}
onRetryRuntimeLoad={runtimeLoader.retry}
onSubmitCreateNote={createFlow.submit}
onSelectNote={selectNote}
@@ -213,7 +216,7 @@ export function MobileApp() {
onCancelCreateNote={createFlow.cancel}
onChangeCreateNoteTitle={createFlow.setTitle}
onOpenCreateNote={createFlow.open}
onGitSyncAction={gitSyncFlow.authenticate}
onGitSyncAction={gitSyncFlow.runPrimaryAction}
onRetryRuntimeLoad={runtimeLoader.retry}
onSubmitCreateNote={createFlow.submit}
onSelectNote={selectNote}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction'
import type { MobileGitRemote } from './mobileGitRemote'
describe('mobile git primary action', () => {
it('routes missing credentials to authentication', () => {
expect(mobileGitPrimaryActionForPlan({
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
})).toEqual({ state: 'authenticate' })
})
it('routes ready pull and push states to transport execution', () => {
expect(mobileGitPrimaryActionForPlan({
canPull: true,
canPush: false,
primaryAction: 'pull',
remote: remote(),
state: 'ready',
})).toMatchObject({ operation: 'pull', state: 'transport' })
expect(mobileGitPrimaryActionForPlan({
canPull: true,
canPush: true,
primaryAction: 'push',
remote: remote(),
state: 'ready',
})).toMatchObject({ operation: 'push', state: 'transport' })
})
it('retries clone failures through authentication and sync failures through transport', () => {
expect(mobileGitPrimaryActionForPlan({
message: 'GitHub authentication failed.',
operation: 'clone',
primaryAction: 'retry',
remote: remote(),
state: 'failed',
})).toEqual({ state: 'authenticate' })
expect(mobileGitPrimaryActionForPlan({
message: 'Pull failed.',
operation: 'pull',
primaryAction: 'retry',
remote: remote(),
state: 'failed',
})).toMatchObject({ operation: 'pull', state: 'transport' })
})
it('ignores local-only and already syncing plans', () => {
expect(mobileGitPrimaryActionForPlan({ primaryAction: null, state: 'localOnly' })).toEqual({ state: 'ignored' })
expect(mobileGitPrimaryActionForPlan({
operation: 'pull',
primaryAction: null,
remote: remote(),
state: 'syncing',
})).toEqual({ state: 'ignored' })
})
})
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -0,0 +1,52 @@
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
import type { MobileGitRemote } from './mobileGitRemote'
export type MobileGitTransportOperation = Exclude<MobileGitOperation, 'clone'>
export type MobileGitPrimaryAction =
| {
state: 'authenticate'
}
| {
operation: MobileGitTransportOperation
remote: MobileGitRemote
state: 'transport'
}
| {
state: 'ignored'
}
export function mobileGitPrimaryActionForPlan(plan: MobileGitSyncPlan): MobileGitPrimaryAction {
if (plan.state === 'authRequired') {
return { state: 'authenticate' }
}
if (plan.state === 'ready') {
return transportAction({
operation: plan.primaryAction,
remote: plan.remote,
})
}
if (plan.state === 'failed') {
return plan.operation === 'clone'
? { state: 'authenticate' }
: transportAction({ operation: plan.operation, remote: plan.remote })
}
return { state: 'ignored' }
}
function transportAction({
operation,
remote,
}: {
operation: MobileGitTransportOperation
remote: MobileGitRemote
}): MobileGitPrimaryAction {
return {
operation,
remote,
state: 'transport',
}
}

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { runMobileGitSyncFlowAction, type MobileGitSyncFlowFailure } from './mobileGitSyncFlowAction'
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
import type { MobileGitRemote } from './mobileGitRemote'
describe('mobile git sync flow action', () => {
it('ignores actions while another operation is active', () => {
const events: string[] = []
runMobileGitSyncFlowAction({
...baseActionInput(events),
activeOperation: 'pull',
})
expect(events).toEqual([])
})
it('runs ready pull and records transport failures', async () => {
const events: string[] = []
const failures: Array<MobileGitSyncFlowFailure | null> = []
runMobileGitSyncFlowAction({
...baseActionInput(events),
gitSyncPlan: readyPlan(),
gitTransport: {
pull: async () => ({ message: 'Pull failed.', state: 'failed' }),
push: async () => ({ state: 'completed' }),
},
setFailure: (failure) => failures.push(failure),
})
await Promise.resolve()
expect(events).toEqual(['active:pull'])
expect(failures).toEqual([null, { message: 'Pull failed.', operation: 'pull' }])
})
it('routes auth-required plans to the authentication operation', () => {
const events: string[] = []
runMobileGitSyncFlowAction({
...baseActionInput(events),
gitSyncPlan: {
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
},
})
expect(events).toEqual(['active:clone'])
})
})
function baseActionInput(events: string[]) {
return {
activeOperation: null,
credentialStorage: {
loadState: async () => ({ state: 'missing' as const }),
remove: async () => {},
saveRecord: async () => {},
},
createGitHubOAuthSession: () => ({
authorize: async () => ({ state: 'cancelled' as const }),
}),
gitSyncPlan: { primaryAction: null, state: 'localOnly' } satisfies MobileGitSyncPlan,
gitTransport: {
pull: async () => ({ state: 'completed' as const }),
push: async () => ({ state: 'completed' as const }),
},
refreshCredentials: () => events.push('refresh'),
setActiveOperation: (operation: MobileGitOperation | null) => {
if (operation) {
events.push(`active:${operation}`)
}
},
setFailure: () => {},
vault: { id: 'personal', name: 'Personal Journal' },
}
}
function readyPlan(): MobileGitSyncPlan {
return {
canPull: true,
canPush: false,
primaryAction: 'pull',
remote: remote(),
state: 'ready',
}
}
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -0,0 +1,134 @@
import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication'
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
import {
runMobileGitTransportOperation,
type MobileGitTransport,
} from './mobileGitTransport'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export type MobileGitSyncFlowFailure = {
message: string
operation: MobileGitOperation
}
export function runMobileGitSyncFlowAction({
activeOperation,
credentialStorage,
createGitHubOAuthSession,
gitSyncPlan,
gitTransport,
refreshCredentials,
setActiveOperation,
setFailure,
vault,
}: {
activeOperation: MobileGitOperation | null
credentialStorage: MobileGitCredentialStorage
createGitHubOAuthSession: () => MobileGitHubOAuthSession
gitSyncPlan: MobileGitSyncPlan
gitTransport: MobileGitTransport
refreshCredentials: () => void
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
vault: MobileVaultMetadata
}) {
if (activeOperation) {
return
}
const action = mobileGitPrimaryActionForPlan(gitSyncPlan)
if (action.state === 'authenticate') {
runAuthenticationAction({
credentialStorage,
createGitHubOAuthSession,
gitSyncPlan,
refreshCredentials,
setActiveOperation,
setFailure,
})
return
}
if (action.state === 'transport') {
runTransportAction({
gitTransport,
operation: action.operation,
remote: action.remote,
setActiveOperation,
setFailure,
vault,
})
}
}
function runAuthenticationAction({
credentialStorage,
createGitHubOAuthSession,
gitSyncPlan,
refreshCredentials,
setActiveOperation,
setFailure,
}: {
credentialStorage: MobileGitCredentialStorage
createGitHubOAuthSession: () => MobileGitHubOAuthSession
gitSyncPlan: MobileGitSyncPlan
refreshCredentials: () => void
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
}) {
setFailure(null)
setActiveOperation('clone')
void authenticateMobileGitSyncPlan({
credentialStorage,
createGitHubOAuthSession,
now: () => new Date().toISOString(),
plan: gitSyncPlan,
})
.then((result) => {
if (result.state === 'connected') {
refreshCredentials()
return
}
if (result.state === 'failed') {
setFailure({ message: result.message, operation: 'clone' })
}
})
.catch(() => setFailure({ message: 'GitHub authentication failed.', operation: 'clone' }))
.finally(() => setActiveOperation(null))
}
function runTransportAction({
gitTransport,
operation,
remote,
setActiveOperation,
setFailure,
vault,
}: {
gitTransport: MobileGitTransport
operation: 'pull' | 'push'
remote: Parameters<typeof runMobileGitTransportOperation>[0]['remote']
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
vault: MobileVaultMetadata
}) {
setFailure(null)
setActiveOperation(operation)
void runMobileGitTransportOperation({
operation,
remote,
transport: gitTransport,
vault,
})
.then((result) => {
if (result.state === 'failed') {
setFailure({ message: result.message, operation })
}
})
.catch(() => setFailure({ message: 'Mobile Git sync failed.', operation }))
.finally(() => setActiveOperation(null))
}

View File

@@ -26,7 +26,7 @@ export type MobileGitSyncPlan =
state: 'ready'
canPull: true
canPush: boolean
primaryAction: MobileGitOperation
primaryAction: 'pull' | 'push'
remote: MobileGitRemote
}
| {

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
createUnavailableMobileGitTransport,
runMobileGitTransportOperation,
type MobileGitTransport,
} from './mobileGitTransport'
import type { MobileGitRemote } from './mobileGitRemote'
describe('mobile git transport', () => {
it('runs the requested transport operation', async () => {
const calls: string[] = []
const transport: MobileGitTransport = {
pull: async () => {
calls.push('pull')
return { state: 'completed' }
},
push: async () => {
calls.push('push')
return { state: 'completed' }
},
}
await runMobileGitTransportOperation({
operation: 'push',
remote: remote(),
transport,
vault: { id: 'personal', name: 'Personal Journal' },
})
expect(calls).toEqual(['push'])
})
it('fails explicitly until a native git implementation is wired', async () => {
await expect(createUnavailableMobileGitTransport().pull({
remote: remote(),
vault: { id: 'personal', name: 'Personal Journal' },
})).resolves.toEqual({
message: 'Mobile Git transport is not available yet.',
state: 'failed',
})
})
})
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -0,0 +1,50 @@
import type { MobileGitRemote } from './mobileGitRemote'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import type { MobileGitTransportOperation } from './mobileGitPrimaryAction'
export type MobileGitTransportRequest = {
remote: MobileGitRemote
vault: MobileVaultMetadata
}
export type MobileGitTransportResult =
| {
state: 'completed'
}
| {
message: string
state: 'failed'
}
export type MobileGitTransport = {
pull: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
push: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
}
export function createUnavailableMobileGitTransport(): MobileGitTransport {
return {
pull: () => unavailableTransportResult(),
push: () => unavailableTransportResult(),
}
}
export function runMobileGitTransportOperation({
operation,
remote,
transport,
vault,
}: {
operation: MobileGitTransportOperation
remote: MobileGitRemote
transport: MobileGitTransport
vault: MobileVaultMetadata
}) {
return transport[operation]({ remote, vault })
}
function unavailableTransportResult(): Promise<MobileGitTransportResult> {
return Promise.resolve({
message: 'Mobile Git transport is not available yet.',
state: 'failed',
})
}

View File

@@ -1,24 +1,27 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import { loadMobileGitCredentialStateForVault } from './mobileGitCredentialStateForVault'
import type { MobileGitCredentialState } from './mobileGitSyncPlan'
import type { MobileGitCredentialState, MobileGitOperation } from './mobileGitSyncPlan'
import { createMobileGitSyncPlanForVault } from './mobileGitSyncRuntimePlan'
import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication'
import { runMobileGitSyncFlowAction, type MobileGitSyncFlowFailure } from './mobileGitSyncFlowAction'
import type { MobileGitTransport } from './mobileGitTransport'
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export function useMobileGitSyncFlow({
createGitHubOAuthSession,
credentialStorage,
gitTransport,
vault,
}: {
createGitHubOAuthSession: () => MobileGitHubOAuthSession
credentialStorage: MobileGitCredentialStorage
gitTransport: MobileGitTransport
vault: MobileVaultMetadata
}) {
const [authFailureMessage, setAuthFailureMessage] = useState<string | null>(null)
const [failure, setFailure] = useState<MobileGitSyncFlowFailure | null>(null)
const [credentials, setCredentials] = useState<MobileGitCredentialState>({ state: 'missing' })
const [isAuthenticating, setIsAuthenticating] = useState(false)
const [activeOperation, setActiveOperation] = useState<MobileGitOperation | null>(null)
const refreshCredentials = useCallback(() => {
void loadMobileGitCredentialStateForVault({ credentialStorage, vault })
.then(setCredentials)
@@ -27,43 +30,30 @@ export function useMobileGitSyncFlow({
const gitSyncPlan = useMemo(
() => createMobileGitSyncPlanForVault({
credentials,
...(authFailureMessage ? { failure: { message: authFailureMessage, operation: 'clone' } } : {}),
...(isAuthenticating ? { operation: 'clone' } : {}),
...(failure ? { failure } : {}),
...(activeOperation ? { operation: activeOperation } : {}),
vault,
}),
[authFailureMessage, credentials, isAuthenticating, vault],
[activeOperation, credentials, failure, vault],
)
const authenticate = useCallback(() => {
if (isAuthenticating) {
return
}
setAuthFailureMessage(null)
setIsAuthenticating(true)
void authenticateMobileGitSyncPlan({
const runPrimaryAction = useCallback(() => {
runMobileGitSyncFlowAction({
activeOperation,
credentialStorage,
createGitHubOAuthSession,
now: () => new Date().toISOString(),
plan: gitSyncPlan,
gitSyncPlan,
gitTransport,
refreshCredentials,
setActiveOperation,
setFailure,
vault,
})
.then((result) => {
if (result.state === 'connected') {
refreshCredentials()
return
}
if (result.state === 'failed') {
setAuthFailureMessage(result.message)
}
})
.catch(() => setAuthFailureMessage('GitHub authentication failed.'))
.finally(() => setIsAuthenticating(false))
}, [createGitHubOAuthSession, credentialStorage, gitSyncPlan, isAuthenticating, refreshCredentials])
}, [activeOperation, createGitHubOAuthSession, credentialStorage, gitSyncPlan, gitTransport, refreshCredentials, vault])
useEffect(refreshCredentials, [refreshCredentials])
return {
authenticate,
gitSyncPlan,
runPrimaryAction,
}
}

View File

@@ -97,13 +97,14 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Exposed the missing `EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID` requirement inside the mobile remote setup prompt and split the client-id detector away from native AuthSession imports.
- Separated the mobile Git remote setup prompt styles from note-creation prompt styles so vault-management UI can evolve without coupling to compose UI names.
- Added a sidebar vault-management card that shows the active app-local vault, Git sync state, and an explicit Git remote action on iPad and compact sidebar surfaces.
- Added the first mobile Git transport execution boundary behind the existing sync/auth plan, including explicit pull/push routing and a visible unavailable-transport failure until the native Git implementation lands.
## Next Action
Continue Phase 4 with editor durability:
1. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA.
2. Start the mobile Git transport execution boundary behind the existing sync/auth plan.
2. Start the native Git engine spike behind `MobileGitTransport`, preferably Rust/libgit2 unless Expo native-module constraints block it.
3. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode.
## Verification Log
@@ -366,6 +367,10 @@ Continue Phase 4 with editor durability:
- `pnpm --filter @tolaria/mobile typecheck` passed after adding the vault-management sidebar card.
- CodeScene after adding the vault-management sidebar card: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileVaultManagementCard.tsx`, `apps/mobile/src/mobileVaultManagementSummary.ts`, `apps/mobile/src/mobileVaultManagementSummary.test.ts`, and `apps/mobile/src/styles/vaultManagementStyles.ts` scored `10`; `apps/mobile/src/styles.ts` returned no scorable code and no findings.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding the vault-management sidebar card; Metro recovered from a cache deserialize warning by doing a full crawl.
- `pnpm --filter @tolaria/mobile test -- src/mobileGitPrimaryAction.test.ts src/mobileGitTransport.test.ts src/mobileGitSyncFlowAction.test.ts src/mobileGitSyncRuntimePlan.test.ts src/mobileGitSyncStatus.test.ts` passed after adding the mobile Git transport execution boundary: 43 files / 143 tests.
- `pnpm --filter @tolaria/mobile typecheck` passed after adding the mobile Git transport execution boundary.
- CodeScene after adding the mobile Git transport execution boundary: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/useMobileGitSyncFlow.ts`, `apps/mobile/src/mobileGitSyncPlan.ts`, `apps/mobile/src/mobileGitPrimaryAction.ts`, `apps/mobile/src/mobileGitPrimaryAction.test.ts`, `apps/mobile/src/mobileGitTransport.ts`, `apps/mobile/src/mobileGitTransport.test.ts`, `apps/mobile/src/mobileGitSyncFlowAction.ts`, and `apps/mobile/src/mobileGitSyncFlowAction.test.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding the mobile Git transport execution boundary.
## Risks / Watch Items