Skip to content
Merged
83 changes: 55 additions & 28 deletions apps/admin/app/routes/_app+/$project._index/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CardTitle,
HStack,
Label,
Progress,
RadioGroup,
RadioGroupItem,
Table,
Expand All @@ -22,13 +23,9 @@ import {
TableRow,
} from '~/components/ui'
import dayjs from '~/libs/dayjs'
import { durablyClient } from '~/services/durably.client'
import type { Route } from './+types/route'
import {
exportFiles,
getProjectDetails,
rescanFiles,
startTranslationJob,
} from './functions.server'
import { exportFiles, getProjectDetails, rescanFiles } from './functions.server'

export const meta = ({ loaderData }: Route.MetaArgs) => [
{ title: `${loaderData?.project.id}` },
Expand Down Expand Up @@ -70,13 +67,6 @@ export const action = async ({ request, params }: Route.ActionArgs) => {
}
}

if (intent === 'start-translation-job') {
return {
intent: 'start-translation-job',
translation_result: await startTranslationJob(projectId),
}
}

if (intent === 'export-files') {
return {
intent: 'export-files',
Expand All @@ -100,13 +90,19 @@ export default function ProjectDetail({
const isRescanInProgress =
navigation.state === 'submitting' &&
navigation.formData?.get('intent') === 'rescan-project'
const isTranslationInProgress =
navigation.state === 'submitting' &&
navigation.formData?.get('intent') === 'start-translation-job'
const isExportInProgress =
navigation.state === 'submitting' &&
navigation.formData?.get('intent') === 'export-files'

// Use durably for translation job
const translationJob = durablyClient['translate-project'].useJob()

const handleStartTranslation = () => {
translationJob.trigger({ projectId: project.id })
}

const isTranslationRunning = translationJob.isRunning

return (
<Card>
<CardHeader>
Expand Down Expand Up @@ -140,11 +136,11 @@ export default function ProjectDetail({
</Button>

<Button
name="intent"
value="start-translation-job"
disabled={isSubmitting}
type="button"
onClick={handleStartTranslation}
disabled={isSubmitting || isTranslationRunning}
>
{isTranslationInProgress && (
{isTranslationRunning && (
<LoaderCircleIcon size="16" className="mr-2 animate-spin" />
)}
Start Translation
Expand Down Expand Up @@ -174,6 +170,45 @@ export default function ProjectDetail({
</HStack>
</Form>

{/* Translation Progress */}
{translationJob.status && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span>
Translation: {translationJob.status}
{translationJob.progress && (
<span className="ml-2 text-muted-foreground">
({translationJob.progress.current}/
{translationJob.progress.total})
</span>
)}
</span>
{translationJob.progress?.message && (
<span className="text-muted-foreground">
{translationJob.progress.message}
</span>
)}
</div>
{translationJob.progress?.total && (
<Progress
value={
(translationJob.progress.current /
translationJob.progress.total) *
100
}
/>
)}
{translationJob.output && (
<div className="text-sm text-muted-foreground">
Completed:{' '}
{(translationJob.output as { translatedCount: number }).translatedCount}{' '}
translated,{' '}
{(translationJob.output as { errorCount: number }).errorCount} errors
</div>
)}
</div>
)}

<div>
{actionData?.intent === 'rescan-project' &&
actionData.rescan_result && (
Expand Down Expand Up @@ -220,14 +255,6 @@ export default function ProjectDetail({
</div>
)}

{actionData?.intent === 'start-translation-job' &&
actionData.translation_result && (
<div>
<div>Translation job started</div>
<div>Job ID: {actionData.translation_result.id}</div>
</div>
)}

{actionData?.intent === 'export-files' &&
actionData.export_result && (
<div>
Expand Down
10 changes: 10 additions & 0 deletions apps/admin/app/routes/api.durably.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { durablyHandler } from '~/services/durably.server'
import type { Route } from './+types/api.durably'

export async function loader({ request }: Route.LoaderArgs) {
return await durablyHandler.handle(request, '/api/durably')
}

export async function action({ request }: Route.ActionArgs) {
return await durablyHandler.handle(request, '/api/durably')
}
6 changes: 6 additions & 0 deletions apps/admin/app/services/durably.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createDurablyClient } from '@coji/durably-react/client'
import type { jobs } from './durably.server'

export const durablyClient = createDurablyClient<typeof jobs>({
api: '/api/durably',
})
123 changes: 123 additions & 0 deletions apps/admin/app/services/durably.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { createDurably, createDurablyHandler, defineJob } from '@coji/durably'
import Database from 'better-sqlite3'
import { SqliteDialect } from 'kysely'
import { z } from 'zod'
import { db, now } from './db.server'
import { translateByGemini } from './translate-gemini'

const DEFAULT_DB_PATH = 'data/dev.db'

const parseDbPath = (url: string): string => {
return url.replace(/^sqlite:\/\//, '').replace(/^file:/, '')
}

const dialect = new SqliteDialect({
database: new Database(
parseDbPath(process.env.DATABASE_URL ?? DEFAULT_DB_PATH),
),
})

// Define translation job
const translationJob = defineJob({
name: 'translate-project',
input: z.object({ projectId: z.string() }),
output: z.object({
translatedCount: z.number(),
errorCount: z.number(),
totalCount: z.number(),
}),
run: async (step, { projectId }) => {
// Fetch project and files
const { files } = await step.run('fetch-data', async () => {
const project = await db
.selectFrom('projects')
.selectAll()
.where('id', '=', projectId)
.executeTakeFirstOrThrow()

const files = await db
.selectFrom('files')
.selectAll()
.where('project_id', '=', projectId)
.where('is_updated', '=', 1)
.orderBy('created_at', 'asc')
.execute()

return { project, files }
})

step.log.info(`Starting translation for project: ${projectId}`)
step.log.info(`Files to translate: ${files.length}`)

let translatedCount = 0
let errorCount = 0

// Translate each file
for (let i = 0; i < files.length; i++) {
const file = files[i]

const result = await step.run(`translate-file-${file.id}`, async () => {
step.log.info(`Translating: ${file.path}`)

const ret = await translateByGemini({
source: file.content,
prevTranslatedText: file.output ?? undefined,
})

if (ret.type === 'success') {
await db
.updateTable('files')
.set({
is_updated: 0,
output: ret.translatedText,
translated_at: now(),
updated_at: now(),
})
.where('id', '=', file.id)
.execute()

step.log.info(`Translated: ${file.path}`)
return { success: true as const, path: file.path }
} else {
step.log.error(`Failed: ${file.path} - ${ret.error}`)
return { success: false as const, path: file.path, error: ret.error }
}
})

if (result.success) {
translatedCount++
} else {
errorCount++
}

// Report progress after each file
step.progress(i + 1, files.length, `Translated ${i + 1}/${files.length}`)
}

step.log.info(
`Translation complete: ${translatedCount} translated, ${errorCount} errors`,
)

return {
translatedCount,
errorCount,
totalCount: files.length,
}
},
})

// Create durably instance and register jobs
export const durably = createDurably({
dialect,
pollingInterval: 1000,
heartbeatInterval: 5000,
staleThreshold: 30000,
}).register({
'translate-project': translationJob,
})

// Export jobs for type inference on client
export const jobs = durably.jobs

// Create handler for API routes
export const durablyHandler = createDurablyHandler(durably)
Comment on lines +108 to +124
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard await durably.init() to avoid repeated init during dev reloads / multi-import.

Module-scope top-level init can run multiple times in some server setups; better to ensure it’s executed once per process.

One possible diff (global singleton promise)
 export const durably = createDurably({
   dialect,
   pollingInterval: 1000,
   heartbeatInterval: 5000,
   staleThreshold: 30000,
 }).register({
   'translate-project': translationJob,
 })

-// Initialize durably tables
-await durably.init()
+// Initialize durably tables (once per process)
+const globalForDurably = globalThis as unknown as {
+  __durablyInitPromise?: Promise<void>
+}
+globalForDurably.__durablyInitPromise ??= durably.init()
+await globalForDurably.__durablyInitPromise

 // Export jobs for type inference on client
 export const jobs = durably.jobs
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const durably = createDurably({
dialect,
pollingInterval: 1000,
heartbeatInterval: 5000,
staleThreshold: 30000,
}).register({
'translate-project': translationJob,
})
// Initialize durably tables
await durably.init()
// Export jobs for type inference on client
export const jobs = durably.jobs
// Create handler for API routes
export const durablyHandler = createDurablyHandler(durably)
export const durably = createDurably({
dialect,
pollingInterval: 1000,
heartbeatInterval: 5000,
staleThreshold: 30000,
}).register({
'translate-project': translationJob,
})
// Initialize durably tables (once per process)
const globalForDurably = globalThis as unknown as {
__durablyInitPromise?: Promise<void>
}
globalForDurably.__durablyInitPromise ??= durably.init()
await globalForDurably.__durablyInitPromise
// Export jobs for type inference on client
export const jobs = durably.jobs
// Create handler for API routes
export const durablyHandler = createDurablyHandler(durably)
🤖 Prompt for AI Agents
In @apps/admin/app/services/durably.server.ts around lines 98 - 114, Top-level
await of durably.init() can run multiple times during dev reloads; guard it by
storing a global singleton promise and awaiting that instead of calling
durably.init() directly. Create or reuse a process-global like
globalThis.__durablyInitPromise (or similar unique name) that is assigned to
durably.init() if not already present, then await that promise; keep the rest of
the exports (durably, jobs, durablyHandler) intact and ensure any TS typing uses
any/casting for the global to avoid compile errors.

2 changes: 2 additions & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
},
"dependencies": {
"@ai-sdk/google": "catalog:",
"@coji/durably": "0.7.0",
"@coji/durably-react": "0.7.0",
"@coji/zodix": "catalog:",
"@conform-to/react": "catalog:",
"@conform-to/zod": "catalog:",
Expand Down
48 changes: 48 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.