All documents

MERIDIAN · DOC 04 / 25

Database

Shared MongoDB model, the six implemented collections, tenantId isolation, indexes, and tracking ID strategy.

MongoDB is the project's database and Mongoose is the ODM. V1 uses one shared MongoDB database with strict tenant isolation enforced by the backend.

Mongoose is used directly — no repository/abstraction layer on top, and no second ORM/ODM in the stack.


1 · Phase state

IMPLEMENTED (through Phase 5)PLANNED FOR FUTURE PHASES
Mongoose connection architecture (src/db/index.ts)Public tracking read projections (Public Tracking phase)
The six locked collections as typed Mongoose modelsRealtime broadcasts of status/location (Realtime phase)
All indexes below (declared in schemas, ensured on dev boot)Isolation test-suite (Phase 11)
Exact five package statuses as Mongoose enumProduction index-ensure as deployment step (Phase 12)
No Customer model anywhere
Dev-time index ensuring + production ensure procedure
Zod request validators mirroring the model shapes
Phase 3: sessions infrastructure collection (TTL, hash-at-rest)
Phase 4: transactional tenant provisioning + rollback fallback
Phase 5: packages actively consumed; status_events and location_history written atomically with their package mutations; server-generated unique tracking IDs (PKG-{TENANT3}-{YYYYMMDD}-{RANDOM6}) minted with collision retry
Phase 6: website_configs actively consumed by the public renderer (no schema change); public tracking reads packages + status_events under the hostname-scoped allowlist; location_history stays server-only

2 · The shared-database model

one MongoDB deployment · one database ("meridian")
 ├── tenants
 ├── users
 ├── packages
 ├── status_events
 ├── location_history
 └── website_configs
        tenant-owned collections carry tenantId on every document

Isolation contract (unchanged, locked): the backend resolves tenantId from host/session and injects it into every query filter; client-supplied tenant identifiers are never trusted; the frontend is not a security boundary. tenantId on documents is the enabler — services enforce the scoping (Phase 4/5).


3 · Collections — IMPLEMENTED IN PHASE 2

tenantsTenantModel (src/db/models/tenant.model.ts)

One logistics company. Identity only — website/branding lives separately in website_configs (locked separation).

FieldTypeRules
companyNameStringrequired, trimmed, ≤ 120
slugStringrequired, unique, lowercase, ^[a-z0-9]+(-[a-z0-9]+)*$, 2–48
statusStringenum ACTIVE / SUSPENDED / ARCHIVED, default ACTIVE
contactembed{ phone?, email?, address? } — validated when present
createdAt / updatedAtDateMongoose timestamps

Indexes: { slug: 1 } unique (subdomain resolution) · { status: 1 } (platform admin lifecycle lists).

usersUserModel (src/db/models/user.model.ts)

The only account-holders. Every user is one of exactly two roles.

FieldTypeRules
nameStringrequired, ≤ 120
emailStringrequired, unique, lowercase, email-validated
passwordHashStringrequired, select: false — never returned by queries
roleStringenum PLATFORM_ADMIN / TENANT_ADMIN (nothing else exists)
tenantIdObjectIdPLATFORM_ADMIN → null · TENANT_ADMIN → required (validated by a pre-validate hook)
statusStringenum ACTIVE / SUSPENDED, default ACTIVE
createdAt / updatedAtDatetimestamps

Indexes: { email: 1 } unique (login) · { tenantId: 1 } (tenant membership) · { role: 1 } (role filtering) · partial unique { role: 1 } where role=PLATFORM_ADMIN (exactly one platform admin account) · partial unique { tenantId: 1 } where role=TENANT_ADMIN (exactly one tenant admin per tenant). The two partial uniques encode the locked cardinality at the database level.

Passwords exist ONLY as passwordHash. Authentication (hashing, sessions) is Phase 3 — this phase defines the data model only.

packagesPackageModel (src/db/models/package.model.ts)

One shipment, tenant-owned, with embedded operational detail.

FieldTypeNotes
tenantIdObjectIdrequired — isolation key
trackingIdStringrequired, globally unique, uppercase, 3–64
packageNameStringrequired, ≤ 160
descriptionStringoptional, ≤ 1000
statusStringenum — the exact five, default PENDING
senderembed{ name*, phone*, email?, address* } — NOT a reference
receiverembed{ name*, phone*, email?, address* } — NOT a reference
specificationsembed{ size?, weight? } — size free text, weight in kg
paymentembed{ paymentMethod? (FREE TEXT), paymentStatus UNPAID/PAID/REFUNDED, shippingCost ≥ 0 }
deliveryembed{ estimatedDeliveryDate? }
currentLocationembed{ latitude?, longitude?, locationName?, updatedAt? } — cached latest fix, NOT history
archivedBooleansoft-remove flag, default false
createdAt / updatedAtDatetimestamps

Indexes: { trackingId: 1 } unique (global lookup) · { tenantId: 1, trackingId: 1 } (tenant-scoped tracking fetch) · { tenantId: 1, status: 1 } (admin work queues) · { tenantId: 1, createdAt: -1 } (newest-first lists) · { tenantId: 1, archived: 1 } (archive filtering).

status_eventsStatusEventModel

Append-only audit trail. Rows are facts; packages.status is merely the cached projection of the latest event.

FieldTypeNotes
tenantIdObjectIdrequired — isolation key
packageIdObjectIdrequired, ref Package
statusStringenum — the exact five
noteStringoptional, ≤ 500
createdAtDatecreatedAt-only timestamps (events never mutate)

Indexes: { packageId: 1, createdAt: 1 } (timeline) · { tenantId: 1, packageId: 1 } (tenant-scoped event reads).

location_historyLocationHistoryModel

Append-only geographic trail. Current location lives on the package; history is never overwritten.

FieldTypeNotes
tenantIdObjectIdrequired — isolation key
packageIdObjectIdrequired, ref Package
latitudeNumberrequired, −90…90
longitudeNumberrequired, −180…180
locationNameStringoptional, ≤ 200
createdAtDatecreatedAt-only timestamps

Indexes: { packageId: 1, createdAt: -1 } (trail + latest position) · { tenantId: 1, packageId: 1 } (tenant-scoped reads). Not exposed to customers — public projection is a later phase.

website_configsWebsiteConfigModel

Configuration-driven tenant website definition — Platform Admin controlled (Tenant Admins have no website editing). One per tenant.

FieldTypeNotes
tenantIdObjectIdrequired, unique — one config per tenant
brandingembed{ logoUrl?, primaryColor?, secondaryColor?, fontFamily?, tagline?, heroHeadline?, heroSubtext? } — hex/URL validated; V1 has no uploads, so logo is a hosted URL
navigationembed[][{ label, href }] — max 10
sectionsembedhero { enabled, headline?, subtext? } · tracking { enabled } · services { enabled, items[{title, description?}] ≤8 } · about { enabled, text? }
contactembed{ phone?, email?, address? } — site-visible contact
socialLinksembed[][{ platform (free text), url }] — max 8, http(s) only
createdAt / updatedAtDatetimestamps

4 · There is NO Customer collection

Explicit, locked decision — and now structurally true: sender/receiver are embedded subdocuments inside Package, not references. No customers collection exists in the model registry (src/db/models/index.ts), and no role beside PLATFORM_ADMIN / TENANT_ADMIN exists.


5 · Tracking ID strategy

DecisionState
Globally unique platform-wideenforced now — unique index
Server-generated, difficult to guessPhase 5 (generation workflow)
Never typed manually by tenant adminshonored by design — create validators accept no trackingId
Exact formatnot lockedPKG-SWL-20260908-0001 is illustrative only

The database guarantee exists in Phase 2; the generation algorithm is a Phase 5 decision.


6 · Why status events and location history are separate collections

Concernstatus_eventslocation_history
Cardinality≤ 5 per package (business checkpoints)dozens–hundreds per package (telemetry)
Write patternrare, transactional with package updatefrequent, fire-and-record
Read patternrender tracking timelinerender map trail / latest position
Retentionkept for package lifetime (audit)may be pruned/sampled later
Indexes{ packageId, createdAt: 1 }{ packageId, createdAt: -1 }

packages.currentLocation is a third thing again: a cached latest fix for cheap reads, updated whenever a history row is appended (Phase 6).


7 · Index strategy — why each exists

Indexes are the minimum set serving the known query shapes; nothing is indexed "just in case":

IndexConsumer
tenants { slug } uniquehost → tenant resolution (Phase 4)
tenants { status }platform admin lifecycle lists
users { email } uniquelogin (Phase 3)
users { tenantId }, { role }membership lists, role filtering
users partial uniques (×2)locked cardinality: 1 platform admin; 1 tenant admin per tenant
packages { trackingId } uniqueglobal uniqueness + public tracking lookup
packages { tenantId, trackingId }tenant-scoped tracking fetch
packages { tenantId, status }tenant work queues
packages { tenantId, createdAt: -1 }newest-first admin lists
packages { tenantId, archived }archive filtering
status_events { packageId, createdAt }tracking timeline
status_events { tenantId, packageId }tenant-scoped event reads
location_history { packageId, createdAt: -1 }trail, latest position
location_history { tenantId, packageId }tenant-scoped reads
website_configs { tenantId } uniqueone config per tenant, config fetch

8 · The five locked statuses (mirrored everywhere)

PENDING · PROCESSED · IN_TRANSIT · ARRIVED_AT_FACILITY · DELIVERED

Canonical definition: src/types/domain.ts → consumed identically by the Mongoose enums, the zod validators, the realtime contracts, and the UI. No sixth status in V1.


9 · Tenant provisioning transactions (Phase 4)

Tenant creation is a multi-document write (tenant → one tenant admin → website config). It runs inside a real MongoDB transaction (withTransaction) — transactions require a replica set or mongos: Atlas tiers and a local single-node replica set (mongod --replSet rs0 + rs.initiate()) qualify; a bare standalone mongod does not. For standalone dev, the service detects the unsupported-transaction error and falls back to sequential creation with compensating deletes on failure — the invariant (no tenant without admin, no orphan configs) holds in both paths and is test-verified against a single-node replica set.

Lifecycle persistence: suspension/archival touch only tenants.status plus sessions cleanup; no tenant-owned document is ever deleted for lifecycle reasons (there is no hard-delete anywhere).


10 · Connection architecture (Phase 1 behaviour, extended in Phase 2)

src/db/index.ts: lazy connectToDatabase() cached on globalThis; MONGODB_URI from env; serverSelectionTimeoutMS: 2500 and bufferCommands: false (fail fast); pino lifecycle logs; pingDatabase() behind /api/health and /api/ready; graceful SIGINT/SIGTERM close.

Phase 2 addition — index management: autoIndex: false at connection time (implicit per-boot index builds are a production anti-pattern). In development, ensureAllIndexes() (src/db/ensure-indexes.ts) runs once after connect and calls createIndexes() for every registered model. In production, the same function is executed as an explicit deployment step (Phase 12) rather than implicitly at boot.