Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

# production
/build
/.compat-worktrees

# misc
.DS_Store
Expand All @@ -29,6 +30,8 @@ yarn-error.log*
.vscode

scripts/*
!scripts/build-deploy.mjs
!scripts/compat-cut.mjs
!scripts/generate-screenshots.mjs
!scripts/encrypt-oracle.ts
!scripts/decrypt-oracle.ts
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 2026-07-04 - Pinned versions by date
Older course material can now link to a frozen version of the validator at /compatibility/<date>/. Each dated version keeps its own question bank, data, and saved work, unaffected by later changes to the live site.

## 2026-03-19 - Changelog added
A changelog has been added to the app to keep you updated on new features and bug fixes.

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ SQL Validator is a fully client-side web application powered by sql.js. Designed
### Public Deployment
A public instance of SQL Validator is available at [https://sql-validator.e-su.se](https://sql-validator.e-su.se), powered with Cloudflare Pages.

### Compatibility Dates
Course material is written against a specific version of the validator (its question bank, generated data, and behavior). To keep older material reproducible when the app changes, the site also serves immutable, dated builds at `/compatibility/<YYYY-MM-DD>/`, e.g. [https://sql-validator.e-su.se/compatibility/2026-07-04/](https://sql-validator.e-su.se/compatibility/2026-07-04/).

A dated build is frozen: its own code, question bank, and expected results, plus its own isolated `localStorage` (namespaced by the date via `src/storage.ts` so it never collides with the live app or other dates on the shared origin).

Each date is an entry in `compatibility.json` (repo root) mapping the date to an immutable commit, e.g. `{ "2026-07-04": "<commit-sha>" }`. On every deploy, the normal build (`npm run build`, which runs `scripts/build-deploy.mjs`) rebuilds each pinned commit under its own base path and assembles the live root plus every snapshot into one `build/` output. The single-app build is `npm run build:app`. The live root at `/` always tracks `master`. No git tags are involved.

**Cutting a new compatibility date:**
1. With `master` checked out at the state to freeze, run:
```
npm run compat:cut # pins today's date to HEAD
npm run compat:cut -- 2026-07-04 master # explicit date / ref
```
This adds `"<date>": "<commit-sha>"` to `compatibility.json` (or edit the file by hand). Only pin commits that contain the compatibility machinery (`src/storage.ts`, the `APP_BASE` handling in `vite.config.ts`, and base-aware asset paths — this feature or later); the build skips a commit that predates it.
2. Commit `compatibility.json` and push to `master`. The next deploy publishes `/compatibility/<date>/` automatically.
3. Don't re-point a published date to a different commit — the dated URL is meant to be permanent (`compat:cut` refuses to move an existing date).

**Cloudflare Pages settings:** unchanged — build command `npm run build`, output directory `build`. When snapshots are configured the build fetches the pinned commits, deepening a shallow clone automatically. A snapshot that fails to build is logged and skipped so the live site still deploys — watch the build log.

### Running Locally
1. Clone the repository: `git clone https://github.com/Edwinexd/sql-validator.git`
2. Install dependencies: `npm install`
Expand Down
1 change: 1 addition & 0 deletions compatibility.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
"scripts": {
"dev": "npm-run-all copy-sqljs start-vite",
"start": "npm-run-all copy-sqljs start-vite",
"build": "npm-run-all copy-sqljs build-vite",
"build": "node scripts/build-deploy.mjs",
"build:app": "npm-run-all copy-sqljs build-vite",
"compat:cut": "node scripts/compat-cut.mjs",
"preview": "vite preview",
"copy-sqljs": "copyfiles -f node_modules/sql.js/dist/sql-wasm.wasm public/dist/sql.js/",
"start-vite": "vite",
Expand Down
144 changes: 144 additions & 0 deletions scripts/build-deploy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
Cloudflare Pages build command for the SQL Validator.

Assembles a single deploy tree:
build/ -> live app (Vite base "/")
build/compatibility/<date>/ -> immutable full-app build pinned by compatibility.json

Compatibility dates are declared in compatibility.json at the repo root:
{ "2026-07-04": "<commit-sha>" }
Each entry maps a date to an immutable commit. This script rebuilds every pinned
commit under its own base path (/compatibility/<date>/) so the pinned build
fetches its own frozen questionpool/database and uses an isolated localStorage
namespace (see src/storage.ts). Pin a date with `npm run compat:cut`.

Only pin commits that already contain the compatibility machinery (this feature
onward); older commits fetch assets from the site root and would not isolate. A
missing-machinery commit is detected and skipped.

This IS the normal build (`npm run build`), so Cloudflare Pages needs no config
change. The single-app build lives in `npm run build:app`, which this script
invokes for the live root and for each snapshot.
*/
import { execSync } from "node:child_process";
import {
existsSync, mkdirSync, rmSync, cpSync, writeFileSync, readFileSync, symlinkSync,
} from "node:fs";
import { join } from "node:path";

const ROOT = process.cwd();
const OUT = join(ROOT, "build");
const WORKTREES = join(ROOT, ".compat-worktrees");
const CONFIG = join(ROOT, "compatibility.json");
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;

const run = (cmd, opts = {}) => execSync(cmd, { stdio: "inherit", ...opts });
const tryRun = (cmd, opts = {}) => { try { run(cmd, { stdio: "ignore", ...opts }); return true; } catch { return false; } };
const capture = (cmd, opts = {}) => execSync(cmd, { encoding: "utf8", ...opts }).trim();

// Cloudflare Pages does a shallow clone; make full history available so any
// pinned commit can be checked out.
function ensureHistory() {
if (!tryRun("git fetch --tags --force --unshallow")) tryRun("git fetch --tags --force");
}

function readConfig() {
if (!existsSync(CONFIG)) { console.warn("! compatibility.json not found; deploying live root only."); return []; }
let raw;
try { raw = JSON.parse(readFileSync(CONFIG, "utf8")); }
catch (e) { console.error(`! compatibility.json is invalid JSON: ${e.message}`); return []; }
const entries = [];
for (const [date, ref] of Object.entries(raw)) {
if (!DATE_RE.test(date)) { console.warn(` skip "${date}": not YYYY-MM-DD`); continue; }
if (typeof ref !== "string" || !ref) { console.warn(` skip ${date}: missing commit`); continue; }
entries.push({ date, ref });
}
entries.sort((a, b) => (a.date < b.date ? 1 : -1)); // newest first
return entries;
}

function resolveCommit(ref) {
try { return capture(`git rev-parse --verify "${ref}^{commit}"`); }
catch {
// Commit may be absent in a shallow clone; try to fetch it, then retry.
tryRun(`git fetch --force origin "${ref}"`);
return capture(`git rev-parse --verify "${ref}^{commit}"`);
}
}

function installDeps(worktree) {
// Reuse the root install when the lockfile is byte-identical (fast path on CF);
// otherwise do a clean install for that commit's pinned dependencies.
const rootLock = readFileSync(join(ROOT, "package-lock.json"), "utf8");
const wtLock = readFileSync(join(worktree, "package-lock.json"), "utf8");
if (wtLock === rootLock && existsSync(join(ROOT, "node_modules"))) {
symlinkSync(join(ROOT, "node_modules"), join(worktree, "node_modules"), "dir");
} else {
run("npm ci", { cwd: worktree });
}
}

function buildSnapshot({ date, ref }) {
const sha = resolveCommit(ref);
// Guard: the pinned commit must contain the compatibility machinery.
if (!tryRun(`git cat-file -e ${sha}:src/storage.ts`)) {
throw new Error(`commit ${sha.slice(0, 10)} predates the compatibility machinery (no src/storage.ts)`);
}
const base = `/compatibility/${date}/`;
const worktree = join(WORKTREES, date);
console.log(`\n=== compatibility snapshot ${date} (${sha.slice(0, 10)}) base=${base} ===`);
run(`git worktree add --detach --force "${worktree}" "${sha}"`);
try {
installDeps(worktree);
run("npm run build:app", { cwd: worktree, env: { ...process.env, APP_BASE: base } });
const dest = join(OUT, "compatibility", date);
mkdirSync(join(OUT, "compatibility"), { recursive: true });
rmSync(dest, { recursive: true, force: true });
cpSync(join(worktree, "build"), dest, { recursive: true });
} finally {
tryRun(`git worktree remove --force "${worktree}"`);
}
}

function writeRoutingFiles(dates) {
// SPA fallback: dated builds fall back to their own index.html, everything else to root.
const redirects = [
...dates.map(d => `/compatibility/${d}/* /compatibility/${d}/index.html 200`),
"/* /index.html 200",
].join("\n") + "\n";
writeFileSync(join(OUT, "_redirects"), redirects);

// Snapshots are immutable -> cache them hard.
const headers = dates.map(d =>
`/compatibility/${d}/*\n Cache-Control: public, max-age=31536000, immutable`
).join("\n\n") + (dates.length ? "\n" : "");
writeFileSync(join(OUT, "_headers"), headers);

mkdirSync(join(OUT, "compatibility"), { recursive: true });
writeFileSync(join(OUT, "compatibility", "index.json"), JSON.stringify(dates) + "\n");
}

// --- run ---------------------------------------------------------------------
const compat = readConfig();
if (compat.length) ensureHistory();

console.log("=== live root build (base /) ===");
run("npm run build:app");

console.log(`\nPinned compatibility date(s): ${compat.map(c => c.date).join(", ") || "none"}`);

rmSync(WORKTREES, { recursive: true, force: true });
mkdirSync(WORKTREES, { recursive: true });

const built = [];
const failed = [];
for (const c of compat) {
try { buildSnapshot(c); built.push(c.date); }
catch (e) { console.error(`! FAILED snapshot ${c.date}: ${e.message}`); failed.push(c.date); }
}
rmSync(WORKTREES, { recursive: true, force: true });

writeRoutingFiles(built);

console.log(`\nDeploy assembled: live root + ${built.length} snapshot(s): ${built.join(", ") || "none"}`);
if (failed.length) console.error(`! ${failed.length} snapshot(s) FAILED and were left out: ${failed.join(", ")}`);
44 changes: 44 additions & 0 deletions scripts/compat-cut.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
Pin a compatibility date to an immutable commit in compatibility.json.

npm run compat:cut # pin today's date to HEAD
npm run compat:cut -- 2026-07-04 # explicit date, HEAD
npm run compat:cut -- 2026-07-04 master # explicit date and ref

Writes compatibility.json; commit and push it to master. The next deploy freezes
that commit at /compatibility/<date>/. No git tags involved. Published dates are
immutable: re-pinning an existing date to a different commit is refused.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { join } from "node:path";

const CONFIG = join(process.cwd(), "compatibility.json");
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const cap = (c) => execSync(c, { encoding: "utf8" }).trim();

const [dateArg, refArg] = process.argv.slice(2);
const date = dateArg || new Date().toISOString().slice(0, 10);
const ref = refArg || "HEAD";

if (!DATE_RE.test(date)) { console.error(`Date must be YYYY-MM-DD, got: ${date}`); process.exit(1); }

let sha, subject;
try {
sha = cap(`git rev-parse --verify "${ref}^{commit}"`);
subject = cap(`git show -s --format=%s ${sha}`);
} catch { console.error(`Could not resolve ref: ${ref}`); process.exit(1); }

const cfg = existsSync(CONFIG) ? JSON.parse(readFileSync(CONFIG, "utf8")) : {};
if (cfg[date] && cfg[date] !== sha) {
console.error(`! ${date} is already pinned to ${cfg[date]}; published dates must stay immutable. Refusing to move it.`);
process.exit(1);
}
cfg[date] = sha;

const sorted = Object.fromEntries(Object.keys(cfg).sort().reverse().map(k => [k, cfg[k]]));
writeFileSync(CONFIG, JSON.stringify(sorted, null, 2) + "\n");

console.log(`Pinned ${date} -> ${sha}`);
console.log(` ${subject}`);
console.log(`\nNext: commit compatibility.json and push to master. The next deploy publishes /compatibility/${date}/.`);
Loading
Loading