Quellcode durchsuchen

Tab-through new-row entry; fix SQLite write contention

After clicking Add One on PDQ.php, all editable cells of the new row
enter edit mode at once so Tab moves natively between inputs (Enter
also advances; Escape cancels; focus leaving the row commits).
jobs_add.php now returns the new job's integer id so the client can
find the row to compose. Polling auto-reload is suppressed while a
compose is in progress so the form is not torn out mid-edit.

While testing, hitting Tab through every field triggered
"database is locked" errors:

  - lib/db.php: install the SQLite busy handler via PDO::ATTR_TIMEOUT
    in the constructor options. PRAGMA busy_timeout via exec() was not
    reliably sticking on this PHP build.
  - lib/jobs.php: replace PDO::beginTransaction() (BEGIN DEFERRED, where
    SQLite skips the busy handler to avoid deadlock) with an explicit
    BEGIN IMMEDIATE, and wrap it in an application-level retry as a
    belt-and-suspenders backstop.
  - assets/app.js (finishCompose): serialize the dirty-field saves into
    a single promise chain instead of firing them all in parallel, so a
    single client can't race itself against the writer lock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bernn vor 1 Woche
Ursprung
Commit
6d48601cbf
4 geänderte Dateien mit 135 neuen und 7 gelöschten Zeilen
  1. 104 2
      assets/app.js
  2. 2 1
      bin/jobs_add.php
  3. 7 1
      lib/db.php
  4. 22 3
      lib/jobs.php

+ 104 - 2
assets/app.js

@@ -208,6 +208,10 @@
 
   // -------- add job (ICG only) --------
 
+  // While this is non-null, polling skips auto-reload so the in-progress
+  // compose form isn't ripped out from under the user.
+  let composeRowId = null;
+
   function wireAddJob() {
     const btn = $('#add-job');
     if (!btn) return;
@@ -217,16 +221,114 @@
       postForm('bin/jobs_add.php', { vendor, ajax: '1' })
         .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
         .then(res => {
-          if (!res.ok || res.body.trim() !== 'Success') {
+          const newId = parseInt((res.body || '').trim(), 10);
+          if (!res.ok || !Number.isInteger(newId) || newId <= 0) {
             alert('Add failed: ' + res.body);
             return;
           }
-          return reloadTable();
+          return reloadTable().then(() => {
+            const row = $('#jobs-table tr[data-job-id="' + newId + '"]');
+            if (row) composeRow(row);
+          });
         })
         .catch(err => alert('Network error: ' + err.message));
     });
   }
 
+  // Open every editable cell of `rowEl` as an <input> simultaneously so the
+  // user can Tab between them. Each input's initial value is captured; on
+  // exit (Escape, Enter past the last cell, or focus leaving the row) only
+  // the changed fields are saved in parallel, then the table reloads once.
+  function composeRow(rowEl) {
+    const jobId = parseInt(rowEl.dataset.jobId, 10);
+    composeRowId = jobId;
+
+    const cells = Array.from(rowEl.querySelectorAll('.editable'));
+    const inputs = cells.map(span => {
+      const col = span.dataset.column;
+      const raw = span.dataset.raw !== undefined ? span.dataset.raw : span.textContent.trim();
+      const initial = raw === '—' ? '' : raw;
+      const input = document.createElement('input');
+      input.type = 'text';
+      input.className = 'inline-edit';
+      input.value = initial;
+      input.dataset.column = col;
+      input.dataset.initial = initial;
+      // The span owns tabindex=0 so it can be entered from the keyboard, but
+      // while it hosts a live input we want Tab to jump straight to the input.
+      span.setAttribute('tabindex', '-1');
+      span.innerHTML = '';
+      span.appendChild(input);
+      return input;
+    });
+
+    inputs.forEach((input, i) => {
+      input.addEventListener('keydown', e => {
+        if (e.key === 'Escape') {
+          e.preventDefault();
+          finishCompose(rowEl, /*save=*/false);
+        } else if (e.key === 'Enter') {
+          e.preventDefault();
+          if (i + 1 < inputs.length) {
+            inputs[i + 1].focus();
+            inputs[i + 1].select();
+          } else {
+            finishCompose(rowEl, true);
+          }
+        }
+      });
+    });
+
+    // Detect focus genuinely leaving the row (not just hopping between its inputs).
+    rowEl.addEventListener('focusout', () => {
+      setTimeout(() => {
+        if (composeRowId === jobId && !rowEl.contains(document.activeElement)) {
+          finishCompose(rowEl, true);
+        }
+      }, 50);
+    });
+
+    if (inputs[0]) { inputs[0].focus(); inputs[0].select(); }
+  }
+
+  function finishCompose(rowEl, save) {
+    if (composeRowId === null) return;
+    const jobId = composeRowId;
+    composeRowId = null;
+
+    const inputs = Array.from(rowEl.querySelectorAll('input.inline-edit'));
+    const dirty = save ? inputs.filter(i => i.value !== i.dataset.initial) : [];
+
+    // Serialize the saves so they queue against the SQLite writer lock
+    // politely instead of all racing at once.
+    const failed = [];
+    const runNext = (i) => {
+      if (i >= dirty.length) return Promise.resolve();
+      const input = dirty[i];
+      return postForm('bin/jobs_update.php', {
+        job_id: jobId,
+        column: input.dataset.column,
+        value: input.value,
+      })
+        .then(r => r.text().then(t => {
+          if (!r.ok || t.trim() !== 'Success') {
+            failed.push(input.dataset.column + ': ' + t);
+          }
+        }))
+        .then(() => runNext(i + 1));
+    };
+
+    runNext(0)
+      .then(() => {
+        if (failed.length) alert('Some saves failed:\n' + failed.join('\n'));
+        return reloadTable();
+      })
+      .catch(err => {
+        alert('Network error: ' + err.message);
+        reloadTable();
+      });
+  }
+
   // -------- polling --------
 
   function isUserBusy() {

+ 2 - 1
bin/jobs_add.php

@@ -21,9 +21,10 @@ $stmt = $pdo->prepare(
     "INSERT INTO jobs(vendor_id, job, ack, status) VALUES(?, 'New', 'new', '')"
 );
 $stmt->execute([$vendor['id']]);
+$new_id = (int) $pdo->lastInsertId();
 
 if (!empty($_POST['ajax'])) {
-    echo 'Success';
+    echo $new_id;
 } else {
     header('Location: ../PDQ.php');
 }

+ 7 - 1
lib/db.php

@@ -12,7 +12,13 @@ function db(): PDO {
     }
     $path = $dir . '/pdq.sqlite';
 
-    $pdo = new PDO('sqlite:' . $path);
+    $pdo = new PDO('sqlite:' . $path, null, null, [
+        // PDO::ATTR_TIMEOUT installs SQLite's busy handler at the connection
+        // level (sqlite3_busy_timeout). Setting it via the constructor
+        // options array applies it before any other call — PRAGMA variants
+        // set via exec() do not reliably stick on this PHP build.
+        PDO::ATTR_TIMEOUT => 5,
+    ]);
     $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
     $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
     $pdo->exec('PRAGMA foreign_keys = ON');

+ 22 - 3
lib/jobs.php

@@ -8,7 +8,21 @@ function apply_job_change(array $job, string $col, $new, string $actor): void {
     $old = $job[$col];
     if ((string) $old === (string) $new) return;
 
-    $pdo->beginTransaction();
+    // Use BEGIN IMMEDIATE so the write lock is acquired up front (PDO's
+    // beginTransaction uses BEGIN DEFERRED, which can short-circuit
+    // SQLite's busy handler). Application-level retry on SQLITE_BUSY
+    // is a backstop in case the PDO timeout isn't honoured.
+    $attempts = 0;
+    while (true) {
+        $attempts++;
+        try {
+            $pdo->exec('BEGIN IMMEDIATE');
+            break;
+        } catch (PDOException $e) {
+            if ($attempts >= 50 || !is_busy_error($e)) throw $e;
+            usleep(100000); // 100 ms
+        }
+    }
     try {
         $stmt = $pdo->prepare("UPDATE jobs SET $col = ?, updated_at = datetime('now') WHERE id = ?");
         $stmt->execute([$new, $job['id']]);
@@ -20,13 +34,18 @@ function apply_job_change(array $job, string $col, $new, string $actor): void {
             );
             $hist->execute([$job['id'], $col, (string) $old, (string) $new, $actor]);
         }
-        $pdo->commit();
+        $pdo->exec('COMMIT');
     } catch (Throwable $e) {
-        $pdo->rollBack();
+        $pdo->exec('ROLLBACK');
         throw $e;
     }
 }
 
+function is_busy_error(PDOException $e): bool {
+    return stripos($e->getMessage(), 'database is locked') !== false
+        || stripos($e->getMessage(), 'database table is locked') !== false;
+}
+
 // Parse the same M-D / M-D-Y forms the original PDQUpdates.php accepted.
 // Returns ISO 'YYYY-MM-DD' or null (which clears the date).
 function parse_due_date(string $raw): ?string {