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:
@@ -1,6 +1,6 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { desc, events, type Db, type EventRow } from "@parking/db";
|
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
|
||||||
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
|
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
|
||||||
|
|
||||||
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
||||||
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
||||||
@@ -16,11 +16,13 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
|
|||||||
// so we guard it with an in-process async lock as well.
|
// so we guard it with an in-process async lock as well.
|
||||||
|
|
||||||
export interface AppendInput {
|
export interface AppendInput {
|
||||||
readonly type: ParkingEventType;
|
readonly type: LedgerEventType;
|
||||||
readonly lane: number;
|
readonly lane: number;
|
||||||
readonly direction?: Direction | null;
|
readonly direction?: Direction | null;
|
||||||
readonly source?: IdentitySource | null;
|
readonly source?: IdentitySource | null;
|
||||||
readonly identity?: string | null;
|
readonly identity?: string | null;
|
||||||
|
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||||
|
readonly payload?: LedgerPayload | null;
|
||||||
/** Event time (ISO-8601). Defaults to now. */
|
/** Event time (ISO-8601). Defaults to now. */
|
||||||
readonly occurredAt?: string;
|
readonly occurredAt?: string;
|
||||||
}
|
}
|
||||||
@@ -39,6 +41,7 @@ export function canonicalize(e: {
|
|||||||
lane: number;
|
lane: number;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
identity: string | null;
|
identity: string | null;
|
||||||
|
payload: Record<string, unknown> | null;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
prevHash: string | null;
|
prevHash: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -49,11 +52,33 @@ export function canonicalize(e: {
|
|||||||
e.lane,
|
e.lane,
|
||||||
e.source ?? null,
|
e.source ?? null,
|
||||||
e.identity ?? null,
|
e.identity ?? null,
|
||||||
|
// Payload is part of the signed form so business data is tamper-evident.
|
||||||
|
// Serialize with sorted keys for byte-stability (object key order must not
|
||||||
|
// change a signature). null when the event type carries no payload.
|
||||||
|
canonicalPayload(e.payload),
|
||||||
e.occurredAt,
|
e.occurredAt,
|
||||||
e.prevHash ?? null,
|
e.prevHash ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
|
||||||
|
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
|
||||||
|
if (p == null) return null;
|
||||||
|
const sort = (v: unknown): unknown => {
|
||||||
|
if (Array.isArray(v)) return v.map(sort);
|
||||||
|
if (v && typeof v === "object") {
|
||||||
|
return Object.keys(v as Record<string, unknown>)
|
||||||
|
.sort()
|
||||||
|
.reduce<Record<string, unknown>>((o, k) => {
|
||||||
|
o[k] = sort((v as Record<string, unknown>)[k]);
|
||||||
|
return o;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
return sort(p);
|
||||||
|
}
|
||||||
|
|
||||||
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
||||||
export function hashEvent(canonical: string): string {
|
export function hashEvent(canonical: string): string {
|
||||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||||
@@ -71,24 +96,25 @@ export class EventLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||||
append(input: AppendInput): Promise<EventRow> {
|
append(input: AppendInput): Promise<LedgerEventRow> {
|
||||||
const run = this.#tail.then(() => this.#appendNow(input));
|
const run = this.#tail.then(() => this.#appendNow(input));
|
||||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||||
this.#tail = run.catch(() => undefined);
|
this.#tail = run.catch(() => undefined);
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
#appendNow(input: AppendInput): EventRow {
|
#appendNow(input: AppendInput): LedgerEventRow {
|
||||||
const prev = this.#db
|
const prev = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(ledgerEvents)
|
||||||
.orderBy(desc(events.index))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const index = (prev?.index ?? 0) + 1;
|
const index = (prev?.index ?? 0) + 1;
|
||||||
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
||||||
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
||||||
|
const payload = input.payload ?? null;
|
||||||
|
|
||||||
const canonical = canonicalize({
|
const canonical = canonicalize({
|
||||||
index,
|
index,
|
||||||
@@ -97,6 +123,7 @@ export class EventLog {
|
|||||||
lane: input.lane,
|
lane: input.lane,
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
|
payload,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
prevHash,
|
prevHash,
|
||||||
});
|
});
|
||||||
@@ -109,13 +136,15 @@ export class EventLog {
|
|||||||
lane: input.lane,
|
lane: input.lane,
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
|
payload,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
prevHash,
|
prevHash,
|
||||||
signature: this.#signer.sign(canonical),
|
signature: this.#signer.sign(canonical),
|
||||||
|
keyId: this.#signer.keyId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#db.insert(events).values(row).run();
|
this.#db.insert(ledgerEvents).values(row).run();
|
||||||
return row as EventRow;
|
return row as LedgerEventRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,7 +154,7 @@ export class EventLog {
|
|||||||
* row (index gap), and a forged/invalid signature.
|
* row (index gap), and a forged/invalid signature.
|
||||||
*/
|
*/
|
||||||
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||||
const rows = this.#db.select().from(events).orderBy(events.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
let expectedIndex = 1;
|
let expectedIndex = 1;
|
||||||
let prevHash: string | null = null;
|
let prevHash: string | null = null;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, events, type Db } from "@parking/db";
|
import { desc, ledgerEvents, type Db } from "@parking/db";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export async function eventRoutes(
|
|||||||
{ preHandler: guard },
|
{ preHandler: guard },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||||
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
|
const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all();
|
||||||
return { events: rows };
|
return { events: rows };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+25
-22
@@ -1,7 +1,8 @@
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
import jwt from "@fastify/jwt";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { createDb, type Db } from "@parking/db";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
@@ -70,39 +71,41 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => printerMonitor.start());
|
app.addHook("onReady", async () => printerMonitor.start());
|
||||||
app.addHook("onClose", async () => printerMonitor.stop());
|
app.addHook("onClose", async () => printerMonitor.stop());
|
||||||
|
|
||||||
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
|
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||||
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
|
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||||
// trail. The device is NOT trusted; the host record is the source of truth, and
|
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||||
// a relay open with no matching signed event is itself the anomaly. We record
|
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||||
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
|
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||||
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
|
// See wiki/decisions/event-streams-split.md.
|
||||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// 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
|
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||||
// faithfully (the chain is append-only) rather than silently dropped or
|
// faithfully rather than silently dropped or mis-stamped as lane 0 (a real lane).
|
||||||
// mis-stamped as lane 0, which is a real lane.
|
|
||||||
const lane = laneMap.laneFor(e.deviceId) ?? -1;
|
const lane = laneMap.laneFor(e.deviceId) ?? -1;
|
||||||
if (lane === -1) {
|
if (lane === -1) {
|
||||||
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
|
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
|
||||||
}
|
}
|
||||||
eventLog
|
try {
|
||||||
.append({
|
db.insert(deviceEventsTable)
|
||||||
type: "input_received",
|
.values({
|
||||||
lane,
|
id: randomUUID(),
|
||||||
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
|
deviceId: e.deviceId,
|
||||||
// VEHICLE was identified. A raw input has none, so it stays null. The
|
lane,
|
||||||
// device provenance lives in `identity` instead.
|
category: "access",
|
||||||
source: null,
|
kind: "input",
|
||||||
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
|
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
||||||
occurredAt: e.at,
|
occurredAt: e.at,
|
||||||
})
|
})
|
||||||
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`device-event insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeInput());
|
app.addHook("onClose", async () => unsubscribeInput());
|
||||||
|
|
||||||
// TODO: entry flow (input event → signed event → print → relay).
|
// TODO: entry flow (device input → signed vehicle_entry → print → relay).
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
CREATE TABLE `events` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`index` integer NOT NULL,
|
|
||||||
`type` text NOT NULL,
|
|
||||||
`direction` text,
|
|
||||||
`lane` integer NOT NULL,
|
|
||||||
`source` text,
|
|
||||||
`identity` text,
|
|
||||||
`occurred_at` text NOT NULL,
|
|
||||||
`prev_hash` text,
|
|
||||||
`signature` text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `events_index_unique` ON `events` (`index`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `users` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`username` text NOT NULL,
|
|
||||||
`password_hash` text NOT NULL,
|
|
||||||
`role` text NOT NULL,
|
|
||||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
CREATE TABLE `blocklist` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`kind` text NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`reason` text,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`added_by` text,
|
||||||
|
`added_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `device_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`device_id` text,
|
||||||
|
`lane` integer,
|
||||||
|
`category` text,
|
||||||
|
`kind` text NOT NULL,
|
||||||
|
`detail` text,
|
||||||
|
`occurred_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `lane_devices` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`lane` integer NOT NULL,
|
||||||
|
`category` text NOT NULL,
|
||||||
|
`driver_id` text NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `ledger_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`index` integer NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`direction` text,
|
||||||
|
`lane` integer NOT NULL,
|
||||||
|
`source` text,
|
||||||
|
`identity` text,
|
||||||
|
`payload` text,
|
||||||
|
`occurred_at` text NOT NULL,
|
||||||
|
`prev_hash` text,
|
||||||
|
`signature` text NOT NULL,
|
||||||
|
`key_id` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `ledger_events_index_unique` ON `ledger_events` (`index`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `permit_credentials` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`permit_id` text NOT NULL,
|
||||||
|
`kind` text NOT NULL,
|
||||||
|
`value` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `permit_plates` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`permit_id` text NOT NULL,
|
||||||
|
`plate` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `permits` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`holder_name` text,
|
||||||
|
`contact` text,
|
||||||
|
`max_concurrent` integer DEFAULT 1,
|
||||||
|
`valid_from` text,
|
||||||
|
`valid_to` text,
|
||||||
|
`status` text DEFAULT 'active' NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`lane` integer,
|
||||||
|
`identity` text,
|
||||||
|
`source` text,
|
||||||
|
`permit_id` text,
|
||||||
|
`entered_at` text NOT NULL,
|
||||||
|
`exited_at` text,
|
||||||
|
`state` text DEFAULT 'open' NOT NULL,
|
||||||
|
`last_event_index` integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `setup_state` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`completed_at` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `tariff_versions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`tariff_id` text NOT NULL,
|
||||||
|
`effective_from` text NOT NULL,
|
||||||
|
`currency` text NOT NULL,
|
||||||
|
`structure` text NOT NULL,
|
||||||
|
`created_by` text,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `tariffs` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`scope` text DEFAULT 'site' NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`role` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
CREATE TABLE `lane_devices` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`lane` integer NOT NULL,
|
|
||||||
`category` text NOT NULL,
|
|
||||||
`driver_id` text NOT NULL,
|
|
||||||
`config` text NOT NULL,
|
|
||||||
`enabled` integer DEFAULT true NOT NULL,
|
|
||||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `setup_state` (
|
|
||||||
`id` integer PRIMARY KEY NOT NULL,
|
|
||||||
`completed_at` text
|
|
||||||
);
|
|
||||||
@@ -1,11 +1,193 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
"id": "cd09c11f-4306-4ac8-a335-7c050d080ab6",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"events": {
|
"blocklist": {
|
||||||
"name": "events",
|
"name": "blocklist",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"added_by": {
|
||||||
|
"name": "added_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"added_at": {
|
||||||
|
"name": "added_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"device_events": {
|
||||||
|
"name": "device_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lane": {
|
||||||
|
"name": "lane",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"name": "detail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"name": "occurred_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"lane_devices": {
|
||||||
|
"name": "lane_devices",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lane": {
|
||||||
|
"name": "lane",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"driver_id": {
|
||||||
|
"name": "driver_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"ledger_events": {
|
||||||
|
"name": "ledger_events",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -56,6 +238,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"payload": {
|
||||||
|
"name": "payload",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"occurred_at": {
|
"occurred_at": {
|
||||||
"name": "occurred_at",
|
"name": "occurred_at",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -76,11 +265,18 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"name": "key_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"events_index_unique": {
|
"ledger_events_index_unique": {
|
||||||
"name": "events_index_unique",
|
"name": "ledger_events_index_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"index"
|
"index"
|
||||||
],
|
],
|
||||||
@@ -92,6 +288,342 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
"permit_credentials": {
|
||||||
|
"name": "permit_credentials",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permit_plates": {
|
||||||
|
"name": "permit_plates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"plate": {
|
||||||
|
"name": "plate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"permits": {
|
||||||
|
"name": "permits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"holder_name": {
|
||||||
|
"name": "holder_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"name": "contact",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"max_concurrent": {
|
||||||
|
"name": "max_concurrent",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"valid_from": {
|
||||||
|
"name": "valid_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"valid_to": {
|
||||||
|
"name": "valid_to",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lane": {
|
||||||
|
"name": "lane",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"name": "identity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"permit_id": {
|
||||||
|
"name": "permit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entered_at": {
|
||||||
|
"name": "entered_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"exited_at": {
|
||||||
|
"name": "exited_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'open'"
|
||||||
|
},
|
||||||
|
"last_event_index": {
|
||||||
|
"name": "last_event_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"setup_state": {
|
||||||
|
"name": "setup_state",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariff_versions": {
|
||||||
|
"name": "tariff_versions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tariff_id": {
|
||||||
|
"name": "tariff_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_from": {
|
||||||
|
"name": "effective_from",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"name": "currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"structure": {
|
||||||
|
"name": "structure",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tariffs": {
|
||||||
|
"name": "tariffs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'site'"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(current_timestamp)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"name": "users",
|
"name": "users",
|
||||||
"columns": {
|
"columns": {
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "6",
|
|
||||||
"dialect": "sqlite",
|
|
||||||
"id": "1073123c-0df9-4109-84bf-7f23b95ec5bd",
|
|
||||||
"prevId": "721bbb8f-b929-4018-9420-0ae75b03ff93",
|
|
||||||
"tables": {
|
|
||||||
"events": {
|
|
||||||
"name": "events",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"index": {
|
|
||||||
"name": "index",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"name": "type",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"direction": {
|
|
||||||
"name": "direction",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"source": {
|
|
||||||
"name": "source",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"identity": {
|
|
||||||
"name": "identity",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"occurred_at": {
|
|
||||||
"name": "occurred_at",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"prev_hash": {
|
|
||||||
"name": "prev_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"signature": {
|
|
||||||
"name": "signature",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"events_index_unique": {
|
|
||||||
"name": "events_index_unique",
|
|
||||||
"columns": [
|
|
||||||
"index"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {}
|
|
||||||
},
|
|
||||||
"lane_devices": {
|
|
||||||
"name": "lane_devices",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"lane": {
|
|
||||||
"name": "lane",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"name": "category",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"driver_id": {
|
|
||||||
"name": "driver_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"name": "config",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"name": "enabled",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "(current_timestamp)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {}
|
|
||||||
},
|
|
||||||
"setup_state": {
|
|
||||||
"name": "setup_state",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"completed_at": {
|
|
||||||
"name": "completed_at",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {}
|
|
||||||
},
|
|
||||||
"users": {
|
|
||||||
"name": "users",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"name": "username",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"password_hash": {
|
|
||||||
"name": "password_hash",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"role": {
|
|
||||||
"name": "role",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "(current_timestamp)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"users_username_unique": {
|
|
||||||
"name": "users_username_unique",
|
|
||||||
"columns": [
|
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"views": {},
|
|
||||||
"enums": {},
|
|
||||||
"_meta": {
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {},
|
|
||||||
"columns": {}
|
|
||||||
},
|
|
||||||
"internal": {
|
|
||||||
"indexes": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,15 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1781389618205,
|
"when": 1781539958008,
|
||||||
"tag": "0000_absent_rocket_raccoon",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1781416636098,
|
|
||||||
"tag": "0001_cuddly_maria_hill",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+164
-10
@@ -2,10 +2,16 @@ import { sql } from "drizzle-orm";
|
|||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
// Schema notes:
|
// Schema notes:
|
||||||
// - `events` is APPEND-ONLY. Never expose UPDATE/DELETE on it. A correction or
|
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||||||
// void is a new row of type 'void'. Each row chains to the previous via
|
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
|
||||||
// `prevHash` and is signed by the ATECC608 (`signature`). This is the core
|
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
|
||||||
// anti-fraud integrity mechanism. See wiki/concepts/append-only-event-chain.md.
|
// 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).
|
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
||||||
// See wiki/entities/local-jwt-auth.md.
|
// See wiki/entities/local-jwt-auth.md.
|
||||||
|
|
||||||
@@ -21,7 +27,13 @@ export const users = sqliteTable("users", {
|
|||||||
.default(sql`(current_timestamp)`),
|
.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(),
|
id: text("id").primaryKey(),
|
||||||
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
|
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
|
||||||
index: integer("index").notNull().unique(),
|
index: integer("index").notNull().unique(),
|
||||||
@@ -30,18 +42,43 @@ export const events = sqliteTable("events", {
|
|||||||
lane: integer("lane").notNull(),
|
lane: integer("lane").notNull(),
|
||||||
source: text("source"),
|
source: text("source"),
|
||||||
identity: text("identity"),
|
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(),
|
occurredAt: text("occurred_at").notNull(),
|
||||||
// Hash of the previous event (hex). Null only for the genesis event.
|
// Hash of the previous event (hex). Null only for the genesis event.
|
||||||
prevHash: text("prev_hash"),
|
prevHash: text("prev_hash"),
|
||||||
// ATECC608 signature over the canonical event payload (hex).
|
// ATECC608 signature over the canonical event payload (hex).
|
||||||
signature: text("signature").notNull(),
|
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
|
// One row per (lane, category, instance). `driverId` references a driver in the
|
||||||
// @parking/devices registry; `config` is that driver's JSON config (host, port,
|
// @parking/devices registry; `config` is that driver's JSON config. Keeps the
|
||||||
// credentials…). Lets the system stay device-agnostic and admin-configurable.
|
// system device-agnostic + admin-configurable. See device-registry.md, first-run-setup.md.
|
||||||
// See wiki/concepts/device-registry.md and first-run-setup.md.
|
|
||||||
export const laneDevices = sqliteTable("lane_devices", {
|
export const laneDevices = sqliteTable("lane_devices", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
lane: integer("lane").notNull(),
|
lane: integer("lane").notNull(),
|
||||||
@@ -64,7 +101,124 @@ export const setupState = sqliteTable("setup_state", {
|
|||||||
completedAt: text("completed_at"),
|
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 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 LaneDeviceRow = typeof laneDevices.$inferSelect;
|
||||||
export type SetupStateRow = typeof setupState.$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;
|
||||||
|
|||||||
@@ -13,39 +13,106 @@ export type Direction = "entry" | "exit";
|
|||||||
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
|
export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An append-only parking event. Records are never mutated; corrections are new
|
* A signed business-LEDGER event. Records are never mutated; corrections are new
|
||||||
* events. `prevHash` chains each event to the previous one; `signature` is the
|
* events. `prevHash` chains each event to the previous one; `signature` is the
|
||||||
* ATECC608 signature over the event contents. See wiki/append-only-event-chain.
|
* ATECC608 signature over the canonical contents (which INCLUDE `payload`).
|
||||||
|
* Distinct from device telemetry — see wiki/decisions/event-streams-split.md.
|
||||||
*/
|
*/
|
||||||
export interface ParkingEvent {
|
export interface LedgerEvent {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly type: ParkingEventType;
|
readonly type: LedgerEventType;
|
||||||
readonly direction: Direction | null;
|
readonly direction: Direction | null;
|
||||||
readonly lane: number;
|
readonly lane: number;
|
||||||
readonly source: IdentitySource | null;
|
readonly source: IdentitySource | null;
|
||||||
/** Card number, plate, ticket id, etc. — depends on `source`. */
|
/** Card number, plate, ticket id, etc. — depends on `source`. */
|
||||||
readonly identity: string | null;
|
readonly identity: string | null;
|
||||||
|
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||||
|
readonly payload: LedgerPayload | null;
|
||||||
readonly occurredAt: string; // ISO-8601
|
readonly occurredAt: string; // ISO-8601
|
||||||
/** Hash of the previous event in the chain (hex). Null only for genesis. */
|
/** Hash of the previous event in the chain (hex). Null only for genesis. */
|
||||||
readonly prevHash: string | null;
|
readonly prevHash: string | null;
|
||||||
/** ATECC608 signature over the canonical event payload (hex). */
|
/** ATECC608 signature over the canonical event payload (hex). */
|
||||||
readonly signature: string;
|
readonly signature: string;
|
||||||
|
/** Which signer/key produced `signature` (verifiable across a signer swap). */
|
||||||
|
readonly keyId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParkingEventType =
|
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
|
||||||
// A raw device input (e.g. a Dingtian button press) was received and recorded.
|
export type LedgerEventType =
|
||||||
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
|
|
||||||
// entry flow once a ticket prints and the barrier is commanded.
|
|
||||||
| "input_received"
|
|
||||||
| "vehicle_entry"
|
| "vehicle_entry"
|
||||||
| "vehicle_exit"
|
| "vehicle_exit"
|
||||||
|
| "payment"
|
||||||
| "void"
|
| "void"
|
||||||
|
// Witness-grade: a host-commanded open, and an independently-observed open
|
||||||
|
// (loop/sensor) — reconciled against each other.
|
||||||
| "barrier_open_command"
|
| "barrier_open_command"
|
||||||
| "barrier_open_observed"
|
| "barrier_open_observed"
|
||||||
| "shift_z_report"
|
| "shift_z_report"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
|
export type Tender = "cash" | "card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-specific data carried on a ledger event's `payload`. All amounts are
|
||||||
|
* integer minor units in the named currency — never floats. Fields are optional
|
||||||
|
* because they're event-type-specific; the producer fills what applies.
|
||||||
|
*/
|
||||||
|
export interface LedgerPayload {
|
||||||
|
/** The parking_session this event concerns (entry/exit/payment/void). */
|
||||||
|
readonly sessionRef?: string;
|
||||||
|
/** payment: amount in minor units, its currency, and how it was tendered. */
|
||||||
|
readonly amountMinor?: number;
|
||||||
|
readonly currency?: string;
|
||||||
|
readonly tender?: Tender;
|
||||||
|
/** payment: which tariff_version priced it (reproducible repricing). */
|
||||||
|
readonly tariffVersionId?: string;
|
||||||
|
/** payment: gross/discount/net split when a validation applied. */
|
||||||
|
readonly grossMinor?: number;
|
||||||
|
readonly discountMinor?: number;
|
||||||
|
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||||
|
readonly fxRate?: number | null;
|
||||||
|
/** void / anomaly / override: a human/machine reason code. */
|
||||||
|
readonly reason?: string;
|
||||||
|
/** plate/vehicle from the vision service (advisory). */
|
||||||
|
readonly plate?: string;
|
||||||
|
readonly plateConfidence?: number;
|
||||||
|
/** Free-form for forward-compat without a schema change. */
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
|
||||||
|
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composable rate card stored in a tariff_version.structure. Pure data the
|
||||||
|
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
|
||||||
|
* a flat rate is just one block. See wiki/concepts/tariff.md.
|
||||||
|
*/
|
||||||
|
export interface TariffStructure {
|
||||||
|
/** Free if exited within this (drop-off/turnaround). */
|
||||||
|
readonly gracePeriodEntryMin: number;
|
||||||
|
/** Billing granularity; partial increments round UP. */
|
||||||
|
readonly incrementMin: number;
|
||||||
|
/** Consumed in order as duration accrues; last block may be open-ended. */
|
||||||
|
readonly blocks: readonly TariffBlock[];
|
||||||
|
/** Cap per rolling 24h (null = no cap). */
|
||||||
|
readonly dailyCapMinor: number | null;
|
||||||
|
/** Flat charge when there's no entry id (admin may override at the moment). */
|
||||||
|
readonly lostTicketMinor: number;
|
||||||
|
/** Pay-on-foot walk-back window: minutes after payment to reach the car. */
|
||||||
|
readonly gracePeriodExitMin: number;
|
||||||
|
/** How an overstay top-up is charged. "reprice" = recompute(entry→now) − paid. */
|
||||||
|
readonly overstay: "reprice";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TariffBlock {
|
||||||
|
/** Upper bound of this block in minutes; null = open-ended (thereafter). */
|
||||||
|
readonly uptoMin: number | null;
|
||||||
|
readonly priceMinorPerIncrement: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const ROLES: readonly Role[] = [
|
export const ROLES: readonly Role[] = [
|
||||||
"admin",
|
"admin",
|
||||||
"operator",
|
"operator",
|
||||||
|
|||||||
Reference in New Issue
Block a user