Files
n.siegfried c084f730b3 T7.7 - CR-007/D8: the sheet travels with the package, and opens offline
The field wants the specific PDF attached, not a link to a Bluebeam session.

Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.

The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.

Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).

Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.

Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.

Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.

Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.

Items: CR-007, D8 (X8 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:09:35 -07:00

127 lines
5.7 KiB
JavaScript

/* Service worker for the Work Package Suite PWA.
Goal: let the app (and especially the field view) load and run offline. Data
durability is already handled by the sync outbox in project-data.js — this
worker only caches the static app shell so the pages open without a network.
Strategy:
• /api/* and non-GET → never touched (pass straight to the network; offline
reads fall back to the app's localStorage cache, writes queue in the outbox).
• HTML / CSS / JS → network-first, cache as fallback. These reference each
other, so a page must never run against a stale sibling.
• images / icons / manifest → stale-while-revalidate (instant from cache).
*/
'use strict';
// Bumped when the shell file list changes, so clients fetch the new assets
// instead of serving a half-old shell from the previous cache.
const CACHE = 'wp-suite-shell-v6';
// CR-007/D8: uploaded drawings, cached at first fetch so an assigned package's
// sheets open with no network. The CLIENT decides what gets fetched (field.js
// prefetches only the requesting user's assigned packages); this worker just
// keeps whatever came through. Never precached - a fresh sign-in starts empty.
const DRAWINGS = 'wp-suite-drawings-v1';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html', '/users.html',
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
'/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js',
'/console-util.js', '/admin.js', '/users.js',
'/prime-controls-logo.jpg', '/favicon.ico',
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
];
self.addEventListener('install', (e) => {
// Cache each shell asset individually so one missing file doesn't abort install.
e.waitUntil(
caches.open(CACHE)
// cache:'reload' bypasses the browser HTTP cache. Without it the precache
// can be filled from stale HTTP entries, freezing a mismatched shell.
.then((c) => Promise.all(SHELL.map(
(u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {}))))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE && k !== DRAWINGS).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
// Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when
// offline. Everything else (images, icons, the manifest) stays cache-first, which is
// where offline speed actually comes from.
//
// Why not cache-first for code: these files reference each other, and the cache
// stores them as independent entries. Cache-first served whichever copy of each file
// happened to be stored, so a browser could run new HTML against old CSS — which is
// exactly how the creator once collapsed to a 300x150 box when it was an iframe
// and its stylesheet was a version behind its markup. A page must
// only ever run against the stylesheet and scripts it shipped with.
const CODE_RE = /\.(html|css|js)$|\/$/i;
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return; // outbox owns writes
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // third-party: default
// Drawing bytes are immutable once uploaded (edits replace the row id), so
// cache-first is safe and is what makes them open offline (CR-007/D8).
if (url.pathname.startsWith('/api/files/')) {
e.respondWith(
caches.open(DRAWINGS).then((c) => c.match(req).then((hit) => hit ||
fetch(req).then((res) => {
if (res && res.ok) c.put(req, res.clone());
return res;
})))
);
return;
}
if (url.pathname.startsWith('/api/')) return; // never cache the API
const isCode = CODE_RE.test(url.pathname);
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
// and the creator used to carry a cache-busting timestamp in its frame src, so keying on the
// full URL both missed every offline navigation and grew the cache without bound.
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
const fromCache = () => caches.match(key).then((c) => c || caches.match(req));
const store = (res) => {
if (res && res.ok && res.type !== 'opaque') {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {});
}
return res;
};
if (isCode) {
e.respondWith(
// cache:'no-cache' forces revalidation with the server. Plain fetch() inherits
// the request's default cache mode, which consults the browser HTTP cache — so
// "network-first" alone still let a page run against a stale sibling file.
fetch(req, { cache: 'no-cache' })
.then((res) => {
// A 502/404 must not replace a page the cache could still serve.
if (!res || !res.ok) return fromCache().then((c) => c || res);
return store(res);
})
.catch(() => fromCache()) // offline → last good copy
.then((res) => res || Response.error()) // never resolve to undefined
);
return;
}
e.respondWith(
caches.match(key).then((cached) => {
const network = fetch(req).then(store).catch(() => cached);
return cached || network.then((res) => res || Response.error());
})
);
});