server+web: capacity / FULL gate (occupancy fold + transient refuse)

Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).

FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).

Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).

Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
This commit is contained in:
2026-06-16 08:13:06 +02:00
parent 644bfa1462
commit e579fe5b6e
13 changed files with 983 additions and 1 deletions
+17
View File
@@ -11,6 +11,7 @@ import {
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceInputEvent } from "./device-events.js";
import { getOccupancy } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import type { LaneMap } from "./lane-map.js";
@@ -77,6 +78,22 @@ export class EntryFlow {
}
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
// subscribers aren't locked out. "Full" is a soft policy seam for valet over-
// capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db);
if (occ.full) {
await this.#log.append({
type: "anomaly",
lane,
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
});
this.#logger.warn(`transient entry REFUSED on lane ${lane}: full (${occ.count}/${occ.capacity})`);
return;
}
const ticketId = newTicketId();
const issuedAt = new Date().toISOString();
const printers = await this.#loadPrinters(lane);
+49
View File
@@ -0,0 +1,49 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
// with no matching vehicle_exit. Never a hand-maintained counter (which is
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
export interface Occupancy {
/** Cars currently inside (open sessions). */
readonly count: number;
/** Admin-set nominal capacity, or null = no limit. */
readonly capacity: number | null;
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
readonly free: number | null;
/** True when count ≥ capacity (always false when uncapped). */
readonly full: boolean;
}
/** Count cars inside: entries minus exits, per identity, over the ledger. */
export function occupancyCount(db: Db): number {
const rows = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.all();
const balance = new Map<string, number>();
for (const r of rows) {
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
}
let open = 0;
for (const v of balance.values()) if (v > 0) open += 1;
return open;
}
/** Admin-set capacity (null = uncapped). */
export function siteCapacity(db: Db): number | null {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return row?.capacity ?? null;
}
export function getOccupancy(db: Db): Occupancy {
const count = occupancyCount(db);
const capacity = siteCapacity(db);
return {
count,
capacity,
free: capacity == null ? null : capacity - count,
full: capacity != null && count >= capacity,
};
}
+43
View File
@@ -0,0 +1,43 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
interface SiteConfigBody {
/** Nominal capacity; null = no limit. */
capacity: number | null;
}
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { capacity: row?.capacity ?? null };
});
// Set capacity (admin). null or 0+ integer.
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const { capacity } = req.body ?? ({} as SiteConfigBody);
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString();
if (existing) {
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
}
return { capacity: capacity ?? null };
});
}
+5
View File
@@ -21,6 +21,7 @@ import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js";
import { permitRoutes } from "./routes/permits.js";
import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js";
import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js";
@@ -129,6 +130,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const shiftService = new ShiftService(db, eventLog, app.log);
await shiftRoutes(app, shiftService);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db);
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded