Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

new row violates check constraintでdocumentsへの保管insertが無音全滅した

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

storageへのアップロードは毎回成功し、documentsテーブルへのinsertだけがnew row violates check constraint documents_source_checkで毎回落ちていた。呼び出し元は本処理を壊さないようこの失敗を無音で握る設計だったため、何ヶ月も気づかれなかった。

結論から

documentsテーブルのsource列には、'upload'と'dropbox'しか許可しないCHECK制約documents_source_checkがかかっていた。ところが自動帳票保管の仕組みは、見積・請求・申込・領収のスナップショットをsource: 'system'で、Anyflow経由の署名済契約書取込をsource: 'anyflow'でinsertしようとする。どちらも許可値に入っていない。

結果、storageへのファイルアップロード自体は毎回成功するのに、documentsテーブルへのinsertだけがnew row for relation "documents" violates check constraint "documents_source_check"で毎回失敗する状態が続いていた。書類金庫に自動保管されたはずの行は、一件も作られていなかった。

失敗を無音で握る設計だった

document-archive.tsのupsertDocumentは、まずstorageにファイルを上げ、その後documents行をupsertする。

async function upsertDocument(
  admin: AdminClient,
  opts: { /* ... */ source: "system" | "anyflow"; /* ... */ }
): Promise<ArchiveResult> {
  const { error: upErr } = await admin.storage
    .from(BUCKET)
    .upload(opts.storagePath, opts.bytes, { contentType: opts.mimeType, upsert: true });
  if (upErr) return { ok: false, reason: `storage: ${upErr.message}` };

  // ...既存行の有無をexternal_refで確認...

  const { data: inserted, error } = await admin
    .from("documents")
    .insert({ ...fields, created_by: null })
    .select("id")
    .single();
  if (error) {
    // 23505(uq_documents_external の競合)は別処理でrace吸収
    // ...
    return { ok: false, reason: `insert: ${error.message}` };
  }
  return { ok: true, documentId: inserted.id };
}

CHECK制約違反(Postgresのエラーコード23514)は、上の23505(一意制約違反のレース)救済パスには当てはまらないので、そのまま{ ok: false, reason: "insert: new row for relation \"documents\" violates check constraint \"documents_source_check\"" }として返る。

この関数を呼ぶarchiveContractDocumentには、意図を説明するコメントがそのまま残っている。

/**
 * 自動生成帳票(見積/請求/申込/領収)を書類金庫へ自動保管する(運営整理 §1)。
 * ...
 * - 呼び出し元(署名フック・請求発行)を壊さないため、例外も含め失敗は ArchiveResult で返す。
 * ...
 */

外部取込を担うarchiveExternalDocumentにも同じ設計意図が書かれている。

/**
 * 外部から取り込んだ署名済ファイル(Anyflow 締結契約書等)を書類金庫へ保管する。
 * ...
 * - 呼び出し元(webhook)を壊さないよう失敗は ArchiveResult で返す。
 */

つまりこれは事故ではなく、意図した設計だった。書類保管の失敗で、入金遷移や署名確定といった本処理まで止めたくない。そのための「失敗はArchiveResultで返すだけ、例外は投げない」という選択自体は妥当だ。問題は、そのArchiveResultを実際に見ている呼び出し元が、どこにも存在しなかったことにある。

誰も.okを見ていなかった

請求書発行のinvoice-issue.tsはこうなっていた。

// 請求書スナップショットを書類金庫へ自動保管(運営整理 §1)。
// 顧客メール未登録でも発行物は残すため、送信可否の判定より前に実行。冪等・失敗は無視。
const archived = await archiveContractDocument({ contractId, kind: "invoice" });

// ...送り先の解決...

const sent = await sendEmail({
  // ...
  text: [
    // ...
    "pdf" in archived && archived.pdf != null
      ? "請求書(PDF)を本メールに添付しています。..."
      : "請求書はこちらからご確認・PDF保存いただけます(ログイン不要):",
    // ...
  ].join("\n"),
  ...("pdf" in archived && archived.pdf != null
    ? { attachments: [{ filename: "請求書.pdf", content: archived.pdf }] }
    : {}),
});

ファイル全体を見ても、archived.okを読んでいる箇所は一つも無い。使われているのはarchived.pdf(メール添付に使うPDFバイト列)だけだ。dryRunを付けていないのでarchiveContractDocumentは実際にstorageとDBへの書き込みを試みるが、PDF生成自体はdocumentsinsertの成否と無関係に行われるため、**保管が失敗していることにメール送信側は一切気づかない。**請求書は正常に発行・送付され続け、書類金庫だけが空のまま積み上がっていった。

署名フロー側のcontract-signed.tsも同様で、コメントにそう明記されている。

// 2b. 申込書スナップショットを書類金庫へ自動保管(運営整理 §1)。署名済=同意の証跡として凍結。
//     非 kimiteras 区分は archiveContractDocument 側でスキップ。冪等・失敗は握りつぶす。
try {
  const r = await archiveContractDocument({ contractId, kind: "application" });
  if (!r.ok) {
    console.error("[contract-signed] application archive failed:", r.reason);
  } else if (!r.skipped) {
    // ...署名の証拠アンカー処理...
  }
}

ここはconsole.errorでログには残すものの、それ以上のアクションは取らない。ログ監視をしていなければ、この1行がCHECK違反で埋まり続けていることは誰にも伝わらない。

修正はCHECK制約を広げるだけ

修正はmigration 0074で、documents_source_checkに許可値'system'と'anyflow'を追加しただけだった。

alter table public.documents drop constraint if exists documents_source_check;
alter table public.documents add constraint documents_source_check
  check (source = any (array['upload'::text, 'dropbox'::text, 'system'::text, 'anyflow'::text]));

drop constraint if exists→add constraintの追加専用・冪等なマイグレーションで、既存データは'upload'のみだったため新しいCHECKにもそのまま適合する。アプリケーション側のコードは一切変更していない——upsertDocumentもinvoice-issue.tsも、元から「正しいCHECK制約さえ通れば動く」コードだった。

「本処理を壊さない」設計の見落としやすい代償

失敗を無音で握って本処理を止めない、という設計判断自体は合理的だ。実際、CHECK違反が直らないまま今回の調査に至るまで、入金処理や署名確定は一度も止まっていない。ただその代償として、「保管は成功しているはず」という前提がどこにも検証されないまま積み重なった。archived.okを一度もチェックしない呼び出し元が複数箇所にあることは、console.errorによるログだけでは発見できない——実際、気づいたきっかけは書類金庫の行数そのものを数えたときだった。無音で握る設計を選ぶなら、握った結果(ok: falseの件数)を別の経路で可視化しておく必要がある。

よくある質問

Q1documents_source_checkは何を制限していましたか?

documentsテーブルのsource列を'upload'と'dropbox'の2値だけに制限するCHECK制約でした。自動スナップショット機能が使うsource='system'と、Anyflow取込機能が使うsource='anyflow'はどちらも許可値に入っておらず、該当するinsertは例外なくnew row for relation documents violates check constraint documents_source_checkで失敗していました。

Q2insertが失敗しているのに、なぜ何ヶ月も気づかなかったのですか?

document-archive.tsのupsertDocumentは、storageへのファイルアップロードに成功した後にdocuments行のinsert/updateを行いますが、呼び出し元(署名フック・請求発行)の本処理を壊さないよう、insert失敗は例外を投げず{ ok: false, reason }というArchiveResultで返す設計でした。実際の呼び出し元であるinvoice-issue.tsはarchived.ok を一度も確認しておらず、archived.pdfだけをメール添付に使っていたため、保管が失敗していても請求書の発行・送付自体は問題なく進んでいました。

Q3どうやって直しましたか?

migration 0074で、既存のdocuments_source_checkをdropしてから、許可値に'system'と'anyflow'を加えたCHECK制約を再作成しました。drop if exists→addの追加専用・冪等な変更で、既存データは'upload'のみだったため新しいCHECKにもそのまま適合します。

確認した環境

  • PostgreSQL(Supabase)・kimiteras-portal
  • 2026-07-12 本番で修正(migration 0074・コミット41e0d03)

この記事の根拠

  • SQLファイル 1〜10行目コミット 41e0d03
  • TypeScriptファイル 219〜295行目コミット 41e0d03
  • TypeScriptファイル 297〜310行目コミット 41e0d03
  • TypeScriptファイル 77〜123行目コミット 606aa03
  • TypeScriptファイル 159〜165行目コミット 606aa03

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。