From 2b8fb2be2b6a82147e8befed79a8f324a896fb9d Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Wed, 29 Jul 2026 11:16:35 -0500 Subject: [PATCH] feat: add delta application refresh --- .env.production.example | 2 +- BACKLOG.md | 2 +- README.md | 2 +- .../EndpointContractTests.cs | 45 +++++++++++++++++++ api/ApplyTrack.Api/ApplyTrack.Api.csproj | 2 +- api/ApplyTrack.Api/Data/ApplicationRepo.cs | 15 +++++++ api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs | 19 +++++++- .../0015_application_list_revision.sql | 33 ++++++++++++++ api/ApplyTrack.Api/wwwroot/api-client.js | 18 ++++++++ api/ApplyTrack.Api/wwwroot/app.js | 25 +++++------ pyproject.toml | 2 +- tests/web/accessibility.spec.js | 35 ++++++++++++++- uv.lock | 2 +- 13 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 api/ApplyTrack.Api/Migrations/0015_application_list_revision.sql 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" },