db: business-layer schema — ledger/device event split, tariffs, permits, sessions
Implements the wiki design in packages/db + packages/shared. Event split: rename events -> ledger_events (signed business ledger) and add device_events (unsigned telemetry). ledger_events gains a signed JSON payload (amount/tariffVersionId/sessionRef/tender…) + keyId; canonicalize() includes the payload via sorted-key serialization so business data is tamper-evident. Raw Dingtian input now writes device_events, not a signed input_received. New tables: tariffs + immutable tariff_versions (composable/versioned, currency + FX-ready), permits (+ permit_credentials, permit_plates; maxConcurrent default 1), blocklist, sessions (rebuildable projection cache — not a source of truth). shared: split ParkingEvent/Type into LedgerEvent/LedgerEventType + DeviceEventKind; add LedgerPayload, Tender, TariffStructure/TariffBlock. Regenerated a single baseline migration (no production chain data existed). Verified: chain appends + verifyChain ok; tampering a payment payload breaks the signature. Full repo builds (5/5).
This commit is contained in:
+164
-10
@@ -2,10 +2,16 @@ import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Schema notes:
|
||||
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or
|
||||
// void is a new row of type 'void'. Each row chains to the previous via
|
||||
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core
|
||||
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md.
|
||||
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||||
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
|
||||
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
|
||||
// chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
|
||||
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
|
||||
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
||||
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
|
||||
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
|
||||
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
|
||||
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
||||
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
||||
// See wiki/entities/local-jwt-auth.md.
|
||||
|
||||
@@ -21,7 +27,13 @@ export const users = sqliteTable("users", {
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export const events = sqliteTable("events", {
|
||||
// --- The signed business ledger (formerly `events`) ----------------------
|
||||
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
|
||||
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
|
||||
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
|
||||
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
|
||||
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
|
||||
export const ledgerEvents = sqliteTable("ledger_events", {
|
||||
id: text("id").primaryKey(),
|
||||
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
|
||||
index: integer("index").notNull().unique(),
|
||||
@@ -30,18 +42,43 @@ export const events = sqliteTable("events", {
|
||||
lane: integer("lane").notNull(),
|
||||
source: text("source"),
|
||||
identity: text("identity"),
|
||||
// Type-specific business payload (JSON). Signed as part of the canonical form.
|
||||
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
occurredAt: text("occurred_at").notNull(),
|
||||
// Hash of the previous event (hex). Null only for the genesis event.
|
||||
prevHash: text("prev_hash"),
|
||||
// ATECC608 signature over the canonical event payload (hex).
|
||||
signature: text("signature").notNull(),
|
||||
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
|
||||
// so old events stay verifiable across a signer swap. See packages/shared Signer.
|
||||
keyId: text("key_id").notNull(),
|
||||
});
|
||||
|
||||
// Per-lane device assignments chosen by the admin during first-run setup.
|
||||
// --- Device telemetry (unsigned, prunable) -------------------------------
|
||||
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
|
||||
// offline, reader read, raw input edges. Keyed to a lane_devices instance; lane is
|
||||
// resolved via the LaneMap. No prevHash/signature — this stream may rotate/prune.
|
||||
export const deviceEvents = sqliteTable("device_events", {
|
||||
id: text("id").primaryKey(),
|
||||
// The lane_devices instance that produced it (raw provenance).
|
||||
deviceId: text("device_id"),
|
||||
lane: integer("lane"),
|
||||
category: text("category", {
|
||||
enum: ["access", "reader", "camera", "printer"],
|
||||
}),
|
||||
// e.g. "input", "relay", "status", "read", "snapshot".
|
||||
kind: text("kind").notNull(),
|
||||
// Free-form telemetry detail (input number + edge, status flags, error…).
|
||||
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
occurredAt: text("occurred_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Per-lane device assignments (first-run setup) -----------------------
|
||||
// One row per (lane, category, instance). `driverId` references a driver in the
|
||||
// @parking/devices registry; `config` is that driver's JSON config (host, port,
|
||||
// credentials…). Lets the system stay device-agnostic and admin-configurable.
|
||||
// See wiki/concepts/device-registry.md and first-run-setup.md.
|
||||
// @parking/devices registry; `config` is that driver's JSON config. Keeps the
|
||||
// system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md.
|
||||
export const laneDevices = sqliteTable("lane_devices", {
|
||||
id: text("id").primaryKey(),
|
||||
lane: integer("lane").notNull(),
|
||||
@@ -64,7 +101,124 @@ export const setupState = sqliteTable("setup_state", {
|
||||
completedAt: text("completed_at"),
|
||||
});
|
||||
|
||||
// --- Tariffs (composable, versioned) -------------------------------------
|
||||
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
||||
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
||||
// A session reprices against the version in force at its entry time; the `payment`
|
||||
// ledger event records the tariffVersionId used. "One active tariff per site" today;
|
||||
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
|
||||
export const tariffs = sqliteTable("tariffs", {
|
||||
id: text("id").primaryKey(),
|
||||
// Only "site" used now; "lane"/"zone" reserved for multi-tariff later.
|
||||
scope: text("scope", { enum: ["site", "lane", "zone"] }).notNull().default("site"),
|
||||
name: text("name").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export const tariffVersions = sqliteTable("tariff_versions", {
|
||||
id: text("id").primaryKey(),
|
||||
tariffId: text("tariff_id").notNull(),
|
||||
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
|
||||
effectiveFrom: text("effective_from").notNull(),
|
||||
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
|
||||
currency: text("currency").notNull(),
|
||||
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
|
||||
// in packages/shared. Immutable once published.
|
||||
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
||||
createdBy: text("created_by"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Permits (subscriptions) ---------------------------------------------
|
||||
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
|
||||
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
|
||||
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
|
||||
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
|
||||
export const permits = sqliteTable("permits", {
|
||||
id: text("id").primaryKey(),
|
||||
holderName: text("holder_name"),
|
||||
contact: text("contact"),
|
||||
// Car-count binding: how many of the permit's cars may be inside at once.
|
||||
// null = unbound. Default 1.
|
||||
maxConcurrent: integer("max_concurrent").default(1),
|
||||
validFrom: text("valid_from"),
|
||||
validTo: text("valid_to"),
|
||||
status: text("status", { enum: ["active", "suspended", "revoked"] })
|
||||
.notNull()
|
||||
.default("active"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
|
||||
export const permitCredentials = sqliteTable("permit_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
// Plate binding (optional). When a permit has plate rows, a matching plate read is
|
||||
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||
export const permitPlates = sqliteTable("permit_plates", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
plate: text("plate").notNull(),
|
||||
});
|
||||
|
||||
// --- Blocklist (banlist) -------------------------------------------------
|
||||
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
||||
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
||||
export const blocklist = sqliteTable("blocklist", {
|
||||
id: text("id").primaryKey(),
|
||||
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
reason: text("reason"),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
addedBy: text("added_by"),
|
||||
addedAt: text("added_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Sessions (PROJECTION cache) -----------------------------------------
|
||||
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
|
||||
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
|
||||
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
// The session key = the entry's identity (ticket id or plate).
|
||||
id: text("id").primaryKey(),
|
||||
lane: integer("lane"),
|
||||
// Identity that opened the session, and how it was read.
|
||||
identity: text("identity"),
|
||||
source: text("source"),
|
||||
// null while transient; set when matched to a permit.
|
||||
permitId: text("permit_id"),
|
||||
enteredAt: text("entered_at").notNull(),
|
||||
// null until exit; presence = CLOSED.
|
||||
exitedAt: text("exited_at"),
|
||||
// Derived state for quick filtering: open | paid | closed | voided.
|
||||
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
|
||||
.notNull()
|
||||
.default("open"),
|
||||
// Index of the last ledger event folded into this row (cache freshness / rebuild).
|
||||
lastEventIndex: integer("last_event_index"),
|
||||
});
|
||||
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type EventRow = typeof events.$inferSelect;
|
||||
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
||||
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
||||
export type LaneDeviceRow = typeof laneDevices.$inferSelect;
|
||||
export type SetupStateRow = typeof setupState.$inferSelect;
|
||||
export type TariffRow = typeof tariffs.$inferSelect;
|
||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||
export type PermitRow = typeof permits.$inferSelect;
|
||||
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
|
||||
export type PermitPlateRow = typeof permitPlates.$inferSelect;
|
||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user