From 0a655684ff92d56574d1518e3a63871f773312f8 Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 21:23:52 +0900 Subject: [PATCH 1/8] feat: replace refresh DB flag polling with direct durably job trigger Instead of storing refreshRequestedAt in the DB and waiting for the hourly scheduler to pick it up, the refresh button now triggers a durably crawl job immediately with real-time progress tracking in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/data-management/index.tsx | 118 +++++++++++------- app/services/jobs/crawl.server.ts | 12 -- app/services/tenant-type.ts | 1 - batch/db/queries.ts | 1 - batch/job-scheduler.ts | 3 +- ...260317120000_drop_refresh_requested_at.sql | 3 + db/migrations/tenant/atlas.sum | 3 +- db/tenant.sql | 1 - 8 files changed, 79 insertions(+), 63 deletions(-) create mode 100644 db/migrations/tenant/20260317120000_drop_refresh_requested_at.sql diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index f1dd2a90..0d5bfe58 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -11,13 +11,10 @@ import { Stack, } from '~/app/components/ui' import { Progress } from '~/app/components/ui/progress' -import { useTimezone } from '~/app/hooks/use-timezone' -import dayjs from '~/app/libs/dayjs' import { orgContext } from '~/app/middleware/context' import { durably } from '~/app/services/durably' import { durably as serverDurably } from '~/app/services/durably.server' import type { JobSteps } from '~/app/services/jobs/shared-steps.server' -import { getTenantDb } from '~/app/services/tenant-db.server' import ContentSection from '../+components/content-section' import type { Route } from './+types/index' @@ -28,18 +25,8 @@ export const handle = { }), } -export const loader = async ({ context }: Route.LoaderArgs) => { - const { organization } = context.get(orgContext) - - const tenantDb = getTenantDb(organization.id) - const organizationSetting = await tenantDb - .selectFrom('organizationSettings') - .select(['refreshRequestedAt']) - .executeTakeFirst() - - return { - refreshRequestedAt: organizationSetting?.refreshRequestedAt ?? null, - } +export const loader = (_args: Route.LoaderArgs) => { + return {} } export const action = async ({ request, context }: Route.ActionArgs) => { @@ -49,13 +36,15 @@ export const action = async ({ request, context }: Route.ActionArgs) => { return match(intent) .with('refresh', async () => { - const tenantDb = getTenantDb(org.id) - await tenantDb - .updateTable('organizationSettings') - .set({ refreshRequestedAt: new Date().toISOString() }) - .execute() + const run = await serverDurably.jobs.crawl.trigger( + { organizationId: org.id, refresh: true }, + { + concurrencyKey: `crawl:${org.id}`, + labels: { organizationId: org.id }, + }, + ) - return data({ intent: 'refresh' as const, ok: true }) + return data({ intent: 'refresh' as const, ok: true, runId: run.id }) }) .with('recalculate', async () => { const selectedSteps = formData.getAll('steps').map(String) @@ -94,50 +83,90 @@ export const action = async ({ request, context }: Route.ActionArgs) => { // --- Refresh Section --- -function RefreshSection({ - refreshRequestedAt, -}: { - refreshRequestedAt: string | null -}) { - const timezone = useTimezone() +function RefreshSection() { const fetcher = useFetcher() const isSubmitting = fetcher.state !== 'idle' - const isScheduled = - refreshRequestedAt != null || - (fetcher.data?.intent === 'refresh' && fetcher.data?.ok === true) + + // After form submission, track the durably run via SSE + const runId = + fetcher.data?.intent === 'refresh' && fetcher.data?.ok + ? fetcher.data.runId + : null + const { + progress, + output, + error: runError, + isPending, + isLeased, + isCompleted, + isFailed, + } = durably.crawl.useRun(runId) + + const isRunning = isPending || isLeased return (

- Schedule Full Refresh - {isScheduled && Scheduled} + Full Refresh + {isRunning && Running}

- Re-fetch all PR data from GitHub on the next hourly crawl. + Re-fetch all PR data from GitHub immediately.

-
- {isScheduled && refreshRequestedAt && ( + + {/* Progress */} + {isRunning && progress && ( - Scheduled at{' '} - {dayjs - .utc(refreshRequestedAt) - .tz(timezone) - .format('YYYY-MM-DD HH:mm:ss')} - . It will run on the next crawl job. +
+

{progress.message ?? 'Processing...'}

+ {progress.current != null && + progress.total != null && + progress.total > 0 && ( + + )} +
+
+
+ )} + + {isRunning && !progress && ( + + Starting full refresh... + + )} + + {/* Success */} + {isCompleted && ( + + + Full refresh completed.{' '} + {output?.pullCount != null && `${output.pullCount} PRs updated.`} )} - {fetcher.data?.error && ( + + {/* Error */} + {isFailed && ( + + Full refresh failed. {runError} + + )} + + {fetcher.data?.intent === 'refresh' && fetcher.data?.error && ( {fetcher.data.error} @@ -333,7 +362,6 @@ function ExportDataSection({ orgSlug }: { orgSlug: string }) { // --- Page --- export default function DataManagementPage({ - loaderData: { refreshRequestedAt }, params: { orgSlug }, }: Route.ComponentProps) { return ( @@ -342,7 +370,7 @@ export default function DataManagementPage({ desc="Manage data refresh and recalculation for this organization." > - + diff --git a/app/services/jobs/crawl.server.ts b/app/services/jobs/crawl.server.ts index d694e99d..e7b18e1f 100644 --- a/app/services/jobs/crawl.server.ts +++ b/app/services/jobs/crawl.server.ts @@ -1,7 +1,6 @@ import { defineJob } from '@coji/durably' import { z } from 'zod' import { clearOrgCache } from '~/app/services/cache.server' -import { getTenantDb } from '~/app/services/tenant-db.server' import type { OrganizationId } from '~/app/types/organization' import { getOrganization } from '~/batch/db/queries' import { createFetcher } from '~/batch/github/fetcher' @@ -151,17 +150,6 @@ export const crawlJob = defineJob({ return { fetchedRepos: repoCount, pullCount: 0 } } - // Consume refresh flag - if (input.refresh) { - await step.run('consume-refresh-flag', async () => { - const tenantDb = getTenantDb(orgId) - await tenantDb - .updateTable('organizationSettings') - .set({ refreshRequestedAt: null }) - .execute() - }) - } - // Steps 3-7: Analyze → Upsert → Classify → Export → Finalize const { pullCount } = await analyzeAndFinalizeSteps( step, diff --git a/app/services/tenant-type.ts b/app/services/tenant-type.ts index ae69b36e..4f072942 100644 --- a/app/services/tenant-type.ts +++ b/app/services/tenant-type.ts @@ -74,7 +74,6 @@ export interface OrganizationSettings { id: string; isActive: Generated<0 | 1>; language: Generated<"en" | "ja">; - refreshRequestedAt: string | null; releaseDetectionKey: Generated; releaseDetectionMethod: Generated<"branch" | "tags">; timezone: Generated; diff --git a/batch/db/queries.ts b/batch/db/queries.ts index 7594a671..b0452661 100644 --- a/batch/db/queries.ts +++ b/batch/db/queries.ts @@ -23,7 +23,6 @@ async function getTenantData(organizationId: OrganizationId) { 'releaseDetectionKey', 'isActive', 'excludedUsers', - 'refreshRequestedAt', ]) .executeTakeFirst(), tenantDb diff --git a/batch/job-scheduler.ts b/batch/job-scheduler.ts index 1baeba72..e3ae9d38 100644 --- a/batch/job-scheduler.ts +++ b/batch/job-scheduler.ts @@ -24,11 +24,10 @@ export const createJobScheduler = () => { if (!org.integration) continue const orgId = org.id as OrganizationId - const refresh = org.organizationSetting.refreshRequestedAt != null try { await durably.jobs.crawl.trigger( - { organizationId: orgId, refresh }, + { organizationId: orgId, refresh: false }, { concurrencyKey: `crawl:${orgId}`, labels: { organizationId: orgId }, diff --git a/db/migrations/tenant/20260317120000_drop_refresh_requested_at.sql b/db/migrations/tenant/20260317120000_drop_refresh_requested_at.sql new file mode 100644 index 00000000..bcdcdf0a --- /dev/null +++ b/db/migrations/tenant/20260317120000_drop_refresh_requested_at.sql @@ -0,0 +1,3 @@ +-- Drop column "refresh_requested_at" from table: "organization_settings" +-- Refresh scheduling is now handled directly via durably jobs instead of DB flag polling +ALTER TABLE `organization_settings` DROP COLUMN `refresh_requested_at`; diff --git a/db/migrations/tenant/atlas.sum b/db/migrations/tenant/atlas.sum index 58fa97bc..6d774d25 100644 --- a/db/migrations/tenant/atlas.sum +++ b/db/migrations/tenant/atlas.sum @@ -1,4 +1,4 @@ -h1:d+Mk98SCf1068WLcRDzr1IE3NMfkpjfAcxDaVbGKYFE= +h1:57BFTZmY6MHNsyxYn2I1qlUPbtDkdrAtNqeUx2hu+Sg= 20260226112249_initial_tenant.sql h1:dIhBg2gzyh+ZjLzPXdHYafd5e62yIEjk1eFlllEyYX0= 20260226233619_add_teams.sql h1:n8MRMUA4BgeXYEnL9HJPc8mnXh8lqIfrCcdYtFFoWqw= 20260227163239.sql h1:ENMZUW7zHK8UjG2TdYlBOZSVPPUCXftIw5U5k2C54oo= @@ -17,3 +17,4 @@ h1:d+Mk98SCf1068WLcRDzr1IE3NMfkpjfAcxDaVbGKYFE= 20260313141746.sql h1:6TvDF7n2gonCaV9ueQSGpYnbaZgltFIeUZEnA/mZbHk= 20260315050936.sql h1:/o/ku2qrlT14mxxhETs26eHguobD5wPYES2khLSN2wA= 20260315120000_add_language.sql h1:O1oFQ+aUAI9+uGdIuhjEV9bM8ImXKMMwRQAw3vYhcVM= +20260317120000_drop_refresh_requested_at.sql h1:R4jHtMkCpdY09orFA4RPvtLeUJ2Z7S4WPMmg/bDJuGg= diff --git a/db/tenant.sql b/db/tenant.sql index 3cc02470..84404135 100644 --- a/db/tenant.sql +++ b/db/tenant.sql @@ -6,7 +6,6 @@ CREATE TABLE `organization_settings` ( `is_active` boolean NOT NULL DEFAULT true, `excluded_users` text NOT NULL DEFAULT '', `timezone` text NOT NULL DEFAULT 'Asia/Tokyo', - `refresh_requested_at` datetime NULL, `updated_at` datetime NOT NULL, `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), PRIMARY KEY (`id`) From 64d2dc2dbe0868e70c942a44065295b844a762ec Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 21:27:15 +0900 Subject: [PATCH 2/8] refactor: extract RunStatusAlerts and remove dead code - Extract shared RunStatusAlerts component to deduplicate progress/status UI between RefreshSection and RecalculateSection - Remove empty loader (no data needed, avoids unnecessary revalidation) - Remove unreachable error alert from RefreshSection - Tighter button disabling: disable when runId exists to prevent gap between submit completion and useRun activation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/data-management/index.tsx | 186 +++++++++--------- 1 file changed, 91 insertions(+), 95 deletions(-) diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index 0d5bfe58..a5dca05c 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -25,10 +25,6 @@ export const handle = { }), } -export const loader = (_args: Route.LoaderArgs) => { - return {} -} - export const action = async ({ request, context }: Route.ActionArgs) => { const { organization: org } = context.get(orgContext) const formData = await request.formData() @@ -81,13 +77,83 @@ export const action = async ({ request, context }: Route.ActionArgs) => { .otherwise(() => data({ error: 'Invalid intent' }, { status: 400 })) } +// --- Shared Run Status Alerts --- + +function RunStatusAlerts({ + label, + progress, + output, + runError, + isRunning, + isCompleted, + isFailed, +}: { + label: string + progress: { message?: string; current?: number; total?: number } | null + output: { pullCount?: number } | null + runError: string | null + isRunning: boolean + isCompleted: boolean + isFailed: boolean +}) { + if (isRunning && progress) { + return ( + + +
+

{progress.message ?? 'Processing...'}

+ {progress.current != null && + progress.total != null && + progress.total > 0 && ( + + )} +
+
+
+ ) + } + + if (isRunning) { + return ( + + Starting {label}... + + ) + } + + if (isCompleted) { + return ( + + + {label} completed.{' '} + {output?.pullCount != null && `${output.pullCount} PRs updated.`} + + + ) + } + + if (isFailed) { + return ( + + + {label} failed. {runError} + + + ) + } + + return null +} + // --- Refresh Section --- function RefreshSection() { const fetcher = useFetcher() const isSubmitting = fetcher.state !== 'idle' - // After form submission, track the durably run via SSE const runId = fetcher.data?.intent === 'refresh' && fetcher.data?.ok ? fetcher.data.runId @@ -103,6 +169,7 @@ function RefreshSection() { } = durably.crawl.useRun(runId) const isRunning = isPending || isLeased + const isBusy = isSubmitting || isRunning || runId != null return ( @@ -118,59 +185,21 @@ function RefreshSection() { - - {/* Progress */} - {isRunning && progress && ( - - -
-

{progress.message ?? 'Processing...'}

- {progress.current != null && - progress.total != null && - progress.total > 0 && ( - - )} -
-
-
- )} - - {isRunning && !progress && ( - - Starting full refresh... - - )} - - {/* Success */} - {isCompleted && ( - - - Full refresh completed.{' '} - {output?.pullCount != null && `${output.pullCount} PRs updated.`} - - - )} - - {/* Error */} - {isFailed && ( - - Full refresh failed. {runError} - - )} - - {fetcher.data?.intent === 'refresh' && fetcher.data?.error && ( - - {fetcher.data.error} - - )} +
) } @@ -184,7 +213,6 @@ function RecalculateSection() { const [exportData, setExportData] = useState(false) const noneSelected = !upsert && !classify && !exportData - // After form submission, track the durably run via SSE const runId = fetcher.data?.intent === 'recalculate' && fetcher.data?.ok ? fetcher.data.runId @@ -266,47 +294,15 @@ function RecalculateSection() {
- {/* Progress */} - {isRunning && progress && ( - - -
-

{progress.message ?? 'Processing...'}

- {progress.current != null && - progress.total != null && - progress.total > 0 && ( - - )} -
-
-
- )} - - {isRunning && !progress && ( - - Starting recalculation... - - )} - - {/* Success */} - {isCompleted && ( - - - Recalculation completed.{' '} - {output?.pullCount != null && `${output.pullCount} PRs updated.`} - - - )} - - {/* Error */} - {isFailed && ( - - Recalculation failed. {runError} - - )} + {fetcher.data?.intent === 'recalculate' && fetcher.data?.error && ( From ca50800553d77d1a995b8700541e3af9f1f5e44c Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 21:39:56 +0900 Subject: [PATCH 3/8] fix: capitalize label at sentence start in RunStatusAlerts Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/$orgSlug/settings/data-management/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index a5dca05c..28371208 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -124,11 +124,13 @@ function RunStatusAlerts({ ) } + const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1) + if (isCompleted) { return ( - {label} completed.{' '} + {capitalizedLabel} completed.{' '} {output?.pullCount != null && `${output.pullCount} PRs updated.`} @@ -139,7 +141,7 @@ function RunStatusAlerts({ return ( - {label} failed. {runError} + {capitalizedLabel} failed. {runError} ) From 4cf2b934a49cdaaf37d1838d27917debffa4a5b2 Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 21:42:20 +0900 Subject: [PATCH 4/8] fix: refresh button stays disabled after job completes runId persists in fetcher.data after completion, so isBusy was always true. Revert to using isRunning for the disabled state. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/$orgSlug/settings/data-management/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index 28371208..fd1929e8 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -171,7 +171,6 @@ function RefreshSection() { } = durably.crawl.useRun(runId) const isRunning = isPending || isLeased - const isBusy = isSubmitting || isRunning || runId != null return ( @@ -187,7 +186,7 @@ function RefreshSection() { - From 08944b5e30a6fde453c0be3f4ea28ed3e247bb93 Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 21:54:58 +0900 Subject: [PATCH 5/8] fix: handle refresh trigger failure with structured error response Wrap durably.jobs.crawl.trigger in try/catch so failures return a structured error instead of a 500. Add error alert in RefreshSection UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/data-management/index.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index fd1929e8..0242c9d8 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -32,15 +32,21 @@ export const action = async ({ request, context }: Route.ActionArgs) => { return match(intent) .with('refresh', async () => { - const run = await serverDurably.jobs.crawl.trigger( - { organizationId: org.id, refresh: true }, - { - concurrencyKey: `crawl:${org.id}`, - labels: { organizationId: org.id }, - }, - ) - - return data({ intent: 'refresh' as const, ok: true, runId: run.id }) + try { + const run = await serverDurably.jobs.crawl.trigger( + { organizationId: org.id, refresh: true }, + { + concurrencyKey: `crawl:${org.id}`, + labels: { organizationId: org.id }, + }, + ) + return data({ intent: 'refresh' as const, ok: true, runId: run.id }) + } catch { + return data( + { intent: 'refresh' as const, error: 'Failed to start refresh' }, + { status: 500 }, + ) + } }) .with('recalculate', async () => { const selectedSteps = formData.getAll('steps').map(String) @@ -201,6 +207,12 @@ function RefreshSection() { isCompleted={isCompleted} isFailed={isFailed} /> + + {fetcher.data?.intent === 'refresh' && fetcher.data?.error && ( + + {fetcher.data.error} + + )} ) } From 5e54f0cc64b44ceffce17c7e37bff3dd1566f6d1 Mon Sep 17 00:00:00 2001 From: coji Date: Tue, 17 Mar 2026 21:59:02 +0900 Subject: [PATCH 6/8] chore: remove dead db/schema.sql and update CLAUDE.md schema.sql was a pre-multi-tenant remnant not referenced by atlas.hcl. Atlas uses shared.sql and tenant.sql as schema sources. Update CLAUDE.md project structure and comments to reflect the actual dual-schema setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 9 +- db/schema.sql | 258 -------------------------------------------------- 2 files changed, 6 insertions(+), 261 deletions(-) delete mode 100644 db/schema.sql diff --git a/CLAUDE.md b/CLAUDE.md index 6b1e76cb..65a78b6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,8 +86,11 @@ batch/ # CLI batch jobs for data processing └── provider/ # GitHub API integration db/ -├── schema.sql # Declarative schema (Atlas source) -├── migrations/ # Atlas versioned migrations +├── shared.sql # Shared DB declarative schema (Atlas source) +├── tenant.sql # Per-org tenant DB declarative schema (Atlas source) +├── migrations/ +│ ├── shared/ # Shared DB versioned migrations +│ └── tenant/ # Tenant DB versioned migrations └── seed.ts # Seed data ``` @@ -109,7 +112,7 @@ Atlas + Kysely setup: - **Kysely**: Runtime queries and type generation via kysely-codegen ```bash -# Generate new migration from schema.sql changes +# Generate new migration from shared.sql / tenant.sql changes pnpm db:migrate # Apply migrations to local database diff --git a/db/schema.sql b/db/schema.sql deleted file mode 100644 index 5a39c6d4..00000000 --- a/db/schema.sql +++ /dev/null @@ -1,258 +0,0 @@ --- Create "users" table -CREATE TABLE `users` ( - `id` text NOT NULL, - `name` text NOT NULL, - `email` text NOT NULL, - `email_verified` boolean NOT NULL, - `image` text NULL, - `role` text NOT NULL, - `banned` boolean NULL, - `ban_reason` text NULL, - `ban_expires` datetime NULL, - `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), - `updated_at` datetime NOT NULL, - PRIMARY KEY (`id`) -); --- Create index "users_email_key" to table: "users" -CREATE UNIQUE INDEX `users_email_key` ON `users` (`email`); --- Create "organizations" table -CREATE TABLE `organizations` ( - `id` text NOT NULL, - `name` text NOT NULL, - `slug` text NOT NULL, - `logo` text NULL, - `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), - `metadata` text NULL, - PRIMARY KEY (`id`) -); --- Create index "organizations_slug_key" to table: "organizations" -CREATE UNIQUE INDEX `organizations_slug_key` ON `organizations` (`slug`); --- Create "sessions" table -CREATE TABLE `sessions` ( - `id` text NOT NULL, - `expires_at` datetime NOT NULL, - `token` text NOT NULL, - `created_at` datetime NOT NULL, - `updated_at` datetime NOT NULL, - `ip_address` text NULL, - `user_agent` text NULL, - `user_id` text NOT NULL, - `impersonated_by` text NULL, - `active_organization_id` text NULL, - `active_team_id` text NULL, - PRIMARY KEY (`id`), - CONSTRAINT `sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "sessions_token_key" to table: "sessions" -CREATE UNIQUE INDEX `sessions_token_key` ON `sessions` (`token`); --- Create "accounts" table -CREATE TABLE `accounts` ( - `id` text NOT NULL, - `account_id` text NOT NULL, - `provider_id` text NOT NULL, - `user_id` text NOT NULL, - `access_token` text NULL, - `refresh_token` text NULL, - `id_token` text NULL, - `access_token_expires_at` datetime NULL, - `refresh_token_expires_at` datetime NULL, - `scope` text NULL, - `password` text NULL, - `created_at` datetime NOT NULL, - `updated_at` datetime NOT NULL, - PRIMARY KEY (`id`), - CONSTRAINT `accounts_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create "verifications" table -CREATE TABLE `verifications` ( - `id` text NOT NULL, - `identifier` text NOT NULL, - `value` text NOT NULL, - `expires_at` datetime NOT NULL, - `created_at` datetime NULL, - `updated_at` datetime NULL, - PRIMARY KEY (`id`) -); --- Create "members" table -CREATE TABLE `members` ( - `id` text NOT NULL, - `organization_id` text NOT NULL, - `user_id` text NOT NULL, - `role` text NOT NULL, - `created_at` datetime NOT NULL, - PRIMARY KEY (`id`), - CONSTRAINT `members_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT `members_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create "invitations" table -CREATE TABLE `invitations` ( - `id` text NOT NULL, - `organization_id` text NOT NULL, - `email` text NOT NULL, - `role` text NULL, - `status` text NOT NULL, - `expires_at` datetime NOT NULL, - `inviter_id` text NOT NULL, - `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), - `team_id` text NULL, - PRIMARY KEY (`id`), - CONSTRAINT `invitations_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT `invitations_inviter_id_fkey` FOREIGN KEY (`inviter_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT `invitations_team_id_fkey` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON UPDATE CASCADE ON DELETE SET NULL -); --- Create "organization_settings" table -CREATE TABLE `organization_settings` ( - `id` text NOT NULL, - `organization_id` text NOT NULL, - `release_detection_method` text NOT NULL DEFAULT 'branch', - `release_detection_key` text NOT NULL DEFAULT 'production', - `is_active` boolean NOT NULL DEFAULT true, - `excluded_users` text NOT NULL DEFAULT '', - `refresh_requested_at` datetime NULL, - `updated_at` datetime NOT NULL, - `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), - PRIMARY KEY (`id`), - CONSTRAINT `organization_settings_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "organization_settings_organization_id_key" to table: "organization_settings" -CREATE UNIQUE INDEX `organization_settings_organization_id_key` ON `organization_settings` (`organization_id`); --- Create "export_settings" table -CREATE TABLE `export_settings` ( - `id` text NOT NULL, - `sheet_id` text NOT NULL, - `client_email` text NOT NULL, - `private_key` text NOT NULL, - `updated_at` datetime NOT NULL, - `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), - `organization_id` text NOT NULL, - PRIMARY KEY (`id`), - CONSTRAINT `export_settings_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "export_settings_organization_id_key" to table: "export_settings" -CREATE UNIQUE INDEX `export_settings_organization_id_key` ON `export_settings` (`organization_id`); --- Create "integrations" table -CREATE TABLE `integrations` ( - `id` text NOT NULL, - `provider` text NOT NULL, - `method` text NOT NULL, - `private_token` text NULL, - `organization_id` text NOT NULL, - PRIMARY KEY (`id`), - CONSTRAINT `integrations_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "integrations_organization_id_key" to table: "integrations" -CREATE UNIQUE INDEX `integrations_organization_id_key` ON `integrations` (`organization_id`); --- Create index "integrations_organization_id_idx" to table: "integrations" -CREATE INDEX `integrations_organization_id_idx` ON `integrations` (`organization_id`); --- Create "repositories" table -CREATE TABLE `repositories` ( - `id` text NOT NULL, - `integration_id` text NOT NULL, - `provider` text NOT NULL, - `owner` text NOT NULL, - `repo` text NOT NULL, - `release_detection_method` text NOT NULL DEFAULT 'branch', - `release_detection_key` text NOT NULL DEFAULT 'production', - `updated_at` datetime NOT NULL, - `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), - `organization_id` text NOT NULL, - PRIMARY KEY (`id`), - CONSTRAINT `repositories_integration_id_fkey` FOREIGN KEY (`integration_id`) REFERENCES `integrations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT `repositories_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "repositories_organization_id_idx" to table: "repositories" -CREATE INDEX `repositories_organization_id_idx` ON `repositories` (`organization_id`); --- Create index "repositories_organization_id_integration_id_owner_repo_key" to table: "repositories" -CREATE UNIQUE INDEX `repositories_organization_id_integration_id_owner_repo_key` ON `repositories` (`organization_id`, `integration_id`, `owner`, `repo`); --- Create "pull_requests" table -CREATE TABLE `pull_requests` ( - `repo` text NOT NULL, - `number` integer NOT NULL, - `source_branch` text NOT NULL, - `target_branch` text NOT NULL, - `state` text NOT NULL, - `author` text NOT NULL, - `title` text NOT NULL, - `url` text NOT NULL, - `first_committed_at` text NULL, - `pull_request_created_at` text NOT NULL, - `first_reviewed_at` text NULL, - `merged_at` text NULL, - `released_at` text NULL, - `coding_time` real NULL, - `pickup_time` real NULL, - `review_time` real NULL, - `deploy_time` real NULL, - `total_time` real NULL, - `repository_id` text NOT NULL, - `updated_at` text NULL, - `additions` integer NULL, - `deletions` integer NULL, - `changed_files` integer NULL, - PRIMARY KEY (`number`, `repository_id`), - CONSTRAINT `pull_requests_repository_id_fkey` FOREIGN KEY (`repository_id`) REFERENCES `repositories` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create "pull_request_reviews" table -CREATE TABLE `pull_request_reviews` ( - `id` text NOT NULL, - `pull_request_number` integer NOT NULL, - `repository_id` text NOT NULL, - `reviewer` text NOT NULL, - `state` text NOT NULL, - `submitted_at` text NOT NULL, - `url` text NOT NULL, - CONSTRAINT `pull_request_reviews_pk` PRIMARY KEY (`id`), - CONSTRAINT `pull_request_reviews_pr_fkey` FOREIGN KEY (`pull_request_number`, `repository_id`) REFERENCES `pull_requests` (`number`, `repository_id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "pull_request_reviews_pr_idx" to table: "pull_request_reviews" -CREATE INDEX `pull_request_reviews_pr_idx` ON `pull_request_reviews` (`pull_request_number`, `repository_id`); --- Create "pull_request_reviewers" table -CREATE TABLE `pull_request_reviewers` ( - `pull_request_number` integer NOT NULL, - `repository_id` text NOT NULL, - `reviewer` text NOT NULL, - `requested_at` text NULL, - CONSTRAINT `pull_request_reviewers_pk` PRIMARY KEY (`pull_request_number`, `repository_id`, `reviewer`), - CONSTRAINT `pull_request_reviewers_pr_fkey` FOREIGN KEY (`pull_request_number`, `repository_id`) REFERENCES `pull_requests` (`number`, `repository_id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create "company_github_users" table -CREATE TABLE `company_github_users` ( - `user_id` text NULL, - `login` text NOT NULL, - `name` text NULL, - `email` text NULL, - `display_name` text NOT NULL, - `updated_at` datetime NOT NULL, - `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), - `organization_id` text NOT NULL, - PRIMARY KEY (`login`, `organization_id`), - CONSTRAINT `company_github_users_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "company_github_users_organization_id_idx" to table: "company_github_users" -CREATE INDEX `company_github_users_organization_id_idx` ON `company_github_users` (`organization_id`); --- Create "teams" table -CREATE TABLE `teams` ( - `id` text NOT NULL, - `name` text NOT NULL, - `organization_id` text NOT NULL, - `created_at` date NOT NULL, - `updated_at` date NULL, - PRIMARY KEY (`id`), - CONSTRAINT `teams_organization_id_fkey` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "teams_organization_id_idx" to table: "teams" -CREATE INDEX `teams_organization_id_idx` ON `teams` (`organization_id`); --- Create "team_members" table -CREATE TABLE `team_members` ( - `id` text NOT NULL, - `team_id` text NOT NULL, - `user_id` text NOT NULL, - `created_at` date NULL, - PRIMARY KEY (`id`), - CONSTRAINT `team_members_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT `team_members_team_id_fkey` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON UPDATE CASCADE ON DELETE CASCADE -); --- Create index "team_members_team_id_idx" to table: "team_members" -CREATE INDEX `team_members_team_id_idx` ON `team_members` (`team_id`); --- Create index "team_members_user_id_idx" to table: "team_members" -CREATE INDEX `team_members_user_id_idx` ON `team_members` (`user_id`); From 717cb545359b758a858962e045703ab6144805e8 Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 18 Mar 2026 08:24:11 +0900 Subject: [PATCH 7/8] fix: handle recalculate trigger failure with structured error response Wrap durably.jobs.recalculate.trigger in try/catch to match the refresh handler, so failures return a structured error instead of a 500. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/data-management/index.tsx | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index 0242c9d8..6602b281 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -66,19 +66,28 @@ export const action = async ({ request, context }: Route.ActionArgs) => { ) } - const run = await serverDurably.jobs.recalculate.trigger( - { organizationId: org.id, steps }, - { - concurrencyKey: `recalculate:${org.id}`, - labels: { organizationId: org.id }, - }, - ) - - return data({ - intent: 'recalculate' as const, - ok: true, - runId: run.id, - }) + try { + const run = await serverDurably.jobs.recalculate.trigger( + { organizationId: org.id, steps }, + { + concurrencyKey: `recalculate:${org.id}`, + labels: { organizationId: org.id }, + }, + ) + return data({ + intent: 'recalculate' as const, + ok: true, + runId: run.id, + }) + } catch { + return data( + { + intent: 'recalculate' as const, + error: 'Failed to start recalculation', + }, + { status: 500 }, + ) + } }) .otherwise(() => data({ error: 'Invalid intent' }, { status: 400 })) } From 05a8315694263b52d271f3931ee3e106876ba4d9 Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 18 Mar 2026 08:27:50 +0900 Subject: [PATCH 8/8] refactor: consolidate trigger error display into RunStatusAlerts Add triggerError prop to RunStatusAlerts to eliminate duplicated fetcher error alert blocks in RefreshSection and RecalculateSection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/data-management/index.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/app/routes/$orgSlug/settings/data-management/index.tsx b/app/routes/$orgSlug/settings/data-management/index.tsx index 6602b281..442f57cf 100644 --- a/app/routes/$orgSlug/settings/data-management/index.tsx +++ b/app/routes/$orgSlug/settings/data-management/index.tsx @@ -99,6 +99,7 @@ function RunStatusAlerts({ progress, output, runError, + triggerError, isRunning, isCompleted, isFailed, @@ -107,6 +108,7 @@ function RunStatusAlerts({ progress: { message?: string; current?: number; total?: number } | null output: { pullCount?: number } | null runError: string | null + triggerError: string | null isRunning: boolean isCompleted: boolean isFailed: boolean @@ -162,6 +164,14 @@ function RunStatusAlerts({ ) } + if (triggerError) { + return ( + + {triggerError} + + ) + } + return null } @@ -212,16 +222,13 @@ function RefreshSection() { progress={progress} output={output} runError={runError} + triggerError={ + fetcher.data?.intent === 'refresh' ? fetcher.data?.error : null + } isRunning={isRunning} isCompleted={isCompleted} isFailed={isFailed} /> - - {fetcher.data?.intent === 'refresh' && fetcher.data?.error && ( - - {fetcher.data.error} - - )} ) } @@ -321,16 +328,13 @@ function RecalculateSection() { progress={progress} output={output} runError={runError} + triggerError={ + fetcher.data?.intent === 'recalculate' ? fetcher.data?.error : null + } isRunning={isRunning} isCompleted={isCompleted} isFailed={isFailed} /> - - {fetcher.data?.intent === 'recalculate' && fetcher.data?.error && ( - - {fetcher.data.error} - - )} ) }