diff --git a/.env.production.example b/.env.production.example
index f6d590a..e2bbb44 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -1,7 +1,7 @@
# Production deployments intentionally have no working secret defaults.
# Copy this file to .env.production, replace every CHANGE-ME value, keep the
# result out of version control, and pin a released OSApplyTrack version.
-OSAPPLYTRACK_VERSION=1.12.0
+OSAPPLYTRACK_VERSION=1.13.0
POSTGRES_USER=applytrack
POSTGRES_PASSWORD=CHANGE-ME-use-a-long-random-password
POSTGRES_DB=applytrack
diff --git a/BACKLOG.md b/BACKLOG.md
index 7b439c1..393eb51 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -15,6 +15,6 @@ or `SPRINTS.md` are not committed backlog until they have a corresponding issue.
## Operations and scalability
- [x] [#53 — Add hardened production container defaults](https://github.com/CryptoJones/OSApplyTrack/issues/53)
-- [ ] [#54 — Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54)
+- [x] [#54 — Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54)
Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/
diff --git a/README.md b/README.md
index b9ca2d0..1f16b62 100644
--- a/README.md
+++ b/README.md
@@ -255,7 +255,7 @@ killing the process:
| Method | Path | Notes |
| --- | --- | --- |
-| `GET` | `/api/apps` | List the tenant's applications. |
+| `GET` | `/api/apps` | List the tenant's applications. Returns a tenant-scoped `ETag`; send it as `If-None-Match` for a cheap `304` when unchanged. Clients without validators still receive the original bare JSON array. |
| `GET` | `/api/stats` | Counts by `{status, lane}`. |
| `GET` | `/api/apps/{name}` | One application: `{filename, raw, fields, version, material}`. |
| `POST` | `/api/apps` | Create from structured fields → `201 {filename}`. |
diff --git a/api/ApplyTrack.Api.Tests/EndpointContractTests.cs b/api/ApplyTrack.Api.Tests/EndpointContractTests.cs
index 6ac514c..2246ce6 100644
--- a/api/ApplyTrack.Api.Tests/EndpointContractTests.cs
+++ b/api/ApplyTrack.Api.Tests/EndpointContractTests.cs
@@ -2,6 +2,7 @@
// Copyright 2026 Aaron K. Clark
using System.Net;
+using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ApplyTrack.Api.Auth;
@@ -101,6 +102,50 @@ public async Task List_summary_has_the_keys_the_sidebar_reads()
Assert.True(row.TryGetProperty(key, out _), $"missing key: {key}");
}
+ [Fact]
+ public async Task Apps_list_etag_returns_304_until_the_tenant_list_changes()
+ {
+ await CreateAcme();
+
+ // No validator preserves the original 200 + bare array contract.
+ var first = await _client.GetAsync("/api/apps");
+ Assert.Equal(HttpStatusCode.OK, first.StatusCode);
+ Assert.Equal(JsonValueKind.Array, (await ReadJson(first)).ValueKind);
+ Assert.NotNull(first.Headers.ETag);
+ Assert.Contains("private", first.Headers.CacheControl?.ToString());
+ Assert.Contains("Cookie", first.Headers.Vary);
+ var originalTag = first.Headers.ETag!;
+
+ // Equal per-tenant revision numbers are not interchangeable validators.
+ var (_, otherSid) = await TestAuth.SeedSessionAsync(_pg.ConnectionString);
+ using var other = _factory.CreateClient();
+ other.DefaultRequestHeaders.Add("Cookie", $"{AuthCookie.Name}={otherSid}");
+ await other.PostAsync(
+ "/api/apps",
+ Json("""{"company":"Other Corp","role":"Engineer"}"""));
+ var otherList = await other.GetAsync("/api/apps");
+ Assert.NotEqual(originalTag.Tag, otherList.Headers.ETag?.Tag);
+
+ using var unchangedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/apps");
+ unchangedRequest.Headers.IfNoneMatch.Add(originalTag);
+ var unchanged = await _client.SendAsync(unchangedRequest);
+ Assert.Equal(HttpStatusCode.NotModified, unchanged.StatusCode);
+ Assert.Equal(originalTag.Tag, unchanged.Headers.ETag?.Tag);
+
+ await _client.PutAsync(
+ "/api/apps/acme-corp-engineer.md",
+ Json("""{"company":"Acme Corp","role":"Principal Engineer"}"""));
+
+ using var changedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/apps");
+ changedRequest.Headers.IfNoneMatch.Add(originalTag);
+ var changed = await _client.SendAsync(changedRequest);
+ Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
+ Assert.NotEqual(originalTag.Tag, changed.Headers.ETag?.Tag);
+ Assert.Equal(
+ "Principal Engineer",
+ (await ReadJson(changed))[0].GetProperty("role").GetString());
+ }
+
[Fact]
public async Task Stats_has_status_and_lane_maps()
{
diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj
index d9c498c..8dc8136 100644
--- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj
+++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj
@@ -5,7 +5,7 @@
enable
enable
ApplyTrack.Api
- 1.12.0
+ 1.13.0
Aaron K. Clark
Copyright 2026 Aaron K. Clark
Apache-2.0
diff --git a/api/ApplyTrack.Api/Data/ApplicationRepo.cs b/api/ApplyTrack.Api/Data/ApplicationRepo.cs
index 0fc0cac..f04bf31 100644
--- a/api/ApplyTrack.Api/Data/ApplicationRepo.cs
+++ b/api/ApplyTrack.Api/Data/ApplicationRepo.cs
@@ -90,6 +90,21 @@ ORDER BY array_position(@order, status), lower(company)
}).ToList();
}
+ ///
+ /// Monotonic tenant-local revision maintained by the applications table
+ /// trigger. This is the cheap first query behind the list endpoint's ETag:
+ /// unchanged refreshes never scan or serialize the applications themselves.
+ ///
+ public async Task ListEtagAsync()
+ {
+ var revision = await _conn.QuerySingleAsync(
+ "SELECT applications_revision FROM users WHERE id = @t",
+ new { t = _t });
+ // Include the tenant even though the revision is tenant-local: two signed-in
+ // accounts with revision 3 do not have interchangeable representations.
+ return $"\"apps-v1-{_t:x}-{revision:x}\"";
+ }
+
///
/// Every application as a full record, for the account export. Ordered by slug so
/// the zip is deterministic. Tenant-scoped like every other read.
diff --git a/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs b/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs
index f204efc..f4bb418 100644
--- a/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs
+++ b/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs
@@ -23,8 +23,23 @@ public sealed record RawUpdate(string Content);
public static void MapAppsEndpoints(this IEndpointRouteBuilder app)
{
- app.MapGet("/api/apps", async (ApplicationRepo repo) =>
- Results.Ok(await repo.ListAsync()));
+ app.MapGet("/api/apps", async (HttpContext context, ApplicationRepo repo) =>
+ {
+ var etag = await repo.ListEtagAsync();
+ context.Response.Headers.ETag = etag;
+ context.Response.Headers.CacheControl = "private, no-cache";
+ context.Response.Headers.Vary = "Cookie";
+
+ var validators = context.Request.Headers.IfNoneMatch.ToString()
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (validators.Any(value =>
+ value == "*" || value == etag || value == $"W/{etag}"))
+ return Results.StatusCode(StatusCodes.Status304NotModified);
+
+ // No validator (backward-compatible client) or a changed revision:
+ // preserve the original bare JSON-array contract exactly.
+ return Results.Ok(await repo.ListAsync());
+ });
app.MapGet("/api/stats", async (ApplicationRepo repo) =>
{
diff --git a/api/ApplyTrack.Api/Migrations/0015_application_list_revision.sql b/api/ApplyTrack.Api/Migrations/0015_application_list_revision.sql
new file mode 100644
index 0000000..e031a46
--- /dev/null
+++ b/api/ApplyTrack.Api/Migrations/0015_application_list_revision.sql
@@ -0,0 +1,33 @@
+-- SPDX-License-Identifier: Apache-2.0
+-- Copyright 2026 Aaron K. Clark
+-- A cheap, tenant-scoped validator for GET /api/apps. Every writer goes through
+-- the applications table, including the Python poller and account imports, so a
+-- database trigger keeps the revision correct without coupling either runtime to
+-- cache invalidation logic.
+ALTER TABLE users
+ ADD COLUMN applications_revision bigint NOT NULL DEFAULT 0;
+
+CREATE FUNCTION bump_application_list_revision()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ affected_tenant bigint;
+BEGIN
+ IF TG_OP = 'DELETE' THEN
+ affected_tenant := OLD.tenant_id;
+ ELSE
+ affected_tenant := NEW.tenant_id;
+ END IF;
+
+ UPDATE users
+ SET applications_revision = applications_revision + 1
+ WHERE id = affected_tenant;
+
+ RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER applications_list_revision
+AFTER INSERT OR UPDATE OR DELETE ON applications
+FOR EACH ROW EXECUTE FUNCTION bump_application_list_revision();
diff --git a/api/ApplyTrack.Api/wwwroot/api-client.js b/api/ApplyTrack.Api/wwwroot/api-client.js
index 770db93..6704c10 100644
--- a/api/ApplyTrack.Api/wwwroot/api-client.js
+++ b/api/ApplyTrack.Api/wwwroot/api-client.js
@@ -24,6 +24,24 @@ export function createApiClient(onUnauthorized) {
return readResponse(await fetch(path, options));
}
+ api.getConditional = async function getConditional(path, etag = "") {
+ const headers = {};
+ if (etag) headers["If-None-Match"] = etag;
+ const response = await fetch(path, { method: "GET", headers });
+ if (response.status === 304) {
+ return {
+ modified: false,
+ data: null,
+ etag: response.headers.get("ETag") || etag,
+ };
+ }
+ return {
+ modified: true,
+ data: await readResponse(response),
+ etag: response.headers.get("ETag") || "",
+ };
+ };
+
api.form = async function form(path, body) {
return readResponse(await fetch(path, { method: "POST", body }));
};
diff --git a/api/ApplyTrack.Api/wwwroot/app.js b/api/ApplyTrack.Api/wwwroot/app.js
index 0f850d5..818d18d 100644
--- a/api/ApplyTrack.Api/wwwroot/app.js
+++ b/api/ApplyTrack.Api/wwwroot/app.js
@@ -21,6 +21,7 @@ const statusGroup = (s) => (s === "passed" ? 2 : APPLIED_STATUSES.has(s) ? 1 : 0
const state = {
apps: [],
+ appsEtag: "",
stats: { status: {}, lane: {} },
query: "",
filterLane: "",
@@ -1259,14 +1260,18 @@ async function loadLlmTab(body, gen = settingsGen) {
// ---- Boot + refresh -------------------------------------------------------
async function refresh() {
- const [apps, stats] = await Promise.all([
- api("GET", "/api/apps"),
- api("GET", "/api/stats"),
- ]);
- state.apps = apps;
+ const list = await api.getConditional("/api/apps", state.appsEtag);
+ if (!list.modified) return false;
+
+ // Stats are derived from the same applications table revision, so an unchanged
+ // list means they are unchanged too. Fetch them only after an ETag miss.
+ const stats = await api("GET", "/api/stats");
+ state.apps = list.data;
state.stats = stats;
+ state.appsEtag = list.etag;
renderPipeline();
renderSidebar();
+ return true;
}
searchEl.addEventListener("input", () => { state.query = searchEl.value; renderSidebar(); });
@@ -1603,15 +1608,7 @@ async function pollApps() {
if (state.mode === "edit" || state.mode === "new" || state.mode === "raw") return;
if (document.hidden) return;
try {
- const [apps, stats] = await Promise.all([
- api("GET", "/api/apps"),
- api("GET", "/api/stats"),
- ]);
- if (JSON.stringify([apps, stats]) === JSON.stringify([state.apps, state.stats])) return;
- state.apps = apps;
- state.stats = stats;
- renderPipeline();
- renderSidebar();
+ await refresh();
} catch (_) {}
}
setInterval(pollApps, POLL_MS);
diff --git a/pyproject.toml b/pyproject.toml
index b82f43a..7f6a47c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "applytrack-poller"
-version = "1.12.0"
+version = "1.13.0"
description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres."
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
diff --git a/tests/web/accessibility.spec.js b/tests/web/accessibility.spec.js
index 45238e8..b6afe09 100644
--- a/tests/web/accessibility.spec.js
+++ b/tests/web/accessibility.spec.js
@@ -43,7 +43,13 @@ async function mockApi(page) {
cover_letters_enabled: true, cover_letter_signature: "", secrets_available: true, has_api_key: false,
base_url: "", model: "", instance: { base_url: "", model: "", has_api_key: false },
};
- else if (path === "/api/apps" && method === "GET") body = [application];
+ else if (path === "/api/apps" && method === "GET") {
+ if (request.headers()["if-none-match"] === '"apps-1"') {
+ await route.fulfill({ status: 304, headers: { ETag: '"apps-1"' } });
+ return;
+ }
+ body = [application];
+ }
else if (path === "/api/stats") body = { status: { lead: 1 }, lane: { dotnet: 1 } };
else if (path === `/api/apps/${application.filename}` && method === "GET") body = detail;
else if (path === "/api/criteria") body = {
@@ -58,7 +64,13 @@ async function mockApi(page) {
else if (path.endsWith("/check-link")) body = { ok: true, summary: "Link is available." };
else if (method === "POST" && path === "/api/poll") body = { count: 0 };
else body = { ok: true, filename: application.filename, cover_letters_enabled: true, cover_letter_signature: "" };
- await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
+ const headers = path === "/api/apps" && method === "GET"
+ ? { ETag: '"apps-1"', "Cache-Control": "private, no-cache" }
+ : {};
+ await route.fulfill({
+ status: 200, contentType: "application/json",
+ headers, body: JSON.stringify(body),
+ });
});
}
@@ -101,6 +113,25 @@ test("dashboard, detail, editor, and preferences pass axe", async ({ page }) =>
await expectNoSeriousViolations(page);
});
+test("unchanged live refresh reuses the ETag without rerendering the list", async ({ page }) => {
+ const originalCard = await page.getByRole("button", { name: /Example Co/ }).elementHandle();
+ let statsRequests = 0;
+ page.on("request", (request) => {
+ if (new URL(request.url()).pathname === "/api/stats") statsRequests += 1;
+ });
+ const revalidation = page.waitForRequest((request) =>
+ new URL(request.url()).pathname === "/api/apps"
+ && request.headers()["if-none-match"] === '"apps-1"');
+
+ // Returning to a visible tab triggers the same live-refresh path immediately.
+ await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
+ await revalidation;
+ await page.waitForTimeout(50);
+
+ expect(await originalCard.evaluate((element) => element.isConnected)).toBe(true);
+ expect(statsRequests).toBe(0);
+});
+
test("settings sections expose labeled controls", async ({ page }) => {
await openSettings(page);
for (const tab of ["Criteria", "Résumé", "AI", "Blacklist", "Account"]) {
diff --git a/uv.lock b/uv.lock
index e90d58f..1fceb72 100644
--- a/uv.lock
+++ b/uv.lock
@@ -23,7 +23,7 @@ wheels = [
[[package]]
name = "applytrack-poller"
-version = "1.12.0"
+version = "1.13.0"
source = { editable = "." }
dependencies = [
{ name = "defusedxml" },