Storage-mønstre
5. Storage-mønstre
Azure Storage er vores datalag — billig, fast, tenant-isoleret. Forståelse af PartitionKey-konventionen er afgørende.
Tabel-strukturen
Vi har et lille håndfuld tabeller (src/lib/storage/tables.ts definerer enum'en TABLES):
export const TABLES = {
Tenants: "Tenants",
TenantModules: "TenantModules",
TenantRoles: "TenantRoles",
RoleAuditLog: "RoleAuditLog",
ContentItems: "ContentItems",
Counters: "Counters",
FormSubmissions: "FormSubmissions",
MediaIndex: "MediaIndex",
} as const;
ContentItems er den store — den indeholder ALT user-genereret content på tværs af alle types.
PartitionKey-konventionen
For at sikre tenant-isolation på storage-niveau, bruger vi composite PartitionKeys:
<tenant-slug>:<type-name>
Eksempel: alle Palle Jacobsen's Artworks ligger med PartitionKey = "palle_jacobsen:Artwork". Alle hans Pages ligger med "palle_jacobsen:Page". Alle Nyborg Rideklub's Ponies ligger med "nyborg_rideklub:Pony".
Det betyder:
- En query mod
PartitionKey = "palle_jacobsen:Page"returnerer ONLY Palle's pages — aldrig Nyborg's eller andre tenants' - Selv en bug i koden der glemmer at filter på tenant-slug kan ikke leak data mellem tenants, fordi storage-laget enforced isolation
- Cross-tenant queries (sjælden — typisk kun for /admin platformside) kræver eksplicit table-scan med filter
Helpers — content-items.ts
src/lib/storage/content-items.ts har CRUD-helpers:
import {
saveContentItem,
getContentItem,
listContentItems,
deleteContentItem,
} from "@/lib/storage/content-items";
// Skriv
await saveContentItem("palle_jacobsen", "Artwork", "sun-1", { title: "Solnedgang", year: 2025, price: 12500 });
// Læs én
const record = await getContentItem("palle_jacobsen", "Artwork", "sun-1");
// → { partitionKey, rowKey, data: { title, year, price, ... } }
// Liste alle af type
const all = await listContentItems("palle_jacobsen", "Artwork");
// → [{ ... }, { ... }, ...]
// Slet
await deleteContentItem("palle_jacobsen", "Artwork", "sun-1");
De wrap'er Azure Tables SDK med tenant-aware error-handling + JSON-decoding af complex felter.
RowKey-konventioner
- Slug for hånd-redigerede content (
forside,om,kontakt) - Sequential ID (
artwork_42) for typer hvor brugeren ikke navngiver records — genereres afgetNextSequentialId() - UUID når slug ikke giver mening (form-submissions)
RowKey må ikke indeholde /, \, #, ?. Slug-validering happen't i admin-UI så du sjældent ser ulovlige tegn.
Counters-tabellen
For at give nye records et incrementing ID (rart for "View #42"-features):
import { getNextSequentialId } from "@/lib/storage/counters";
const id = await getNextSequentialId("palle_jacobsen", "Artwork");
// → 42 (og næste call returnerer 43)
Intern: én Counters-row pr. (tenant, type) med en numerisk counter. Race-conditions håndteres via ETag-baseret optimistic concurrency.
Blob-storage
Media (billeder, dokumenter) bor i blob-container media:
<tenant-slug>/<type>/<id>/<filename>
Eksempler:
palle_jacobsen/Hero/hero_main/cover.jpgnyborg_rideklub/Page/forside/banner.pngtesseracms/Document/doc_5/regulativ.pdf
Direkte upload via src/lib/storage/blobs.ts:uploadBlob(). Helper'en sikrer:
- Tenant-prefix er korrekt
- Filename er sanitized (no
.., no path-traversal) - Content-type header er sat
- Blob er offentligt læsbart (vi cache'r ved CDN'en hvis vi nogensinde får en)
For at få URL'en til en blob: getBlobUrl(tenant, type, id, filename) returnerer en absolute URL.
MediaIndex
For at undgå table-scan af blob-storage hver gang vi viser MediaPicker, har vi MediaIndex-tabellen der spejler metadata:
- PartitionKey:
<tenant-slug>:Media - RowKey:
<sha256-hash-af-path> - Data: path, filename, mime, size, width, height, altText, refCount, lastUsedAt
Index opdateres ved upload + delete. MediaPicker query'er kun mod indexet — aldrig direkte mod blob-storage.
Refs: når en page bruger et billede, increment'er vi refCount på det blob. Når page slettes, decrementeres. Det forhindrer at vi sletter blobs i brug.
Migrations
Når vi laver brydende ændringer i data-shape, skriver vi en script under scripts/migrate-*.ts:
- Itererer over relevante records
- Læser gammel shape, skriver ny shape
- Idempotent: kan kaldes flere gange uden side-effekter
- Logger hvad der ændredes
Køres typisk mod Azurite først for at validere, derefter mod prod.
Eksempel: scripts/migrate-hero-to-generic.ts flyttede hero-content fra inline-fields til separate Hero-records.
Performance-overvejelser
- Single-partition query (=
PartitionKey eq X) er typisk <100 ms - Cross-partition query (filter på andre felter) skanner hele tabellen — undgå hvis muligt
- Batch-write (transaction) er begrænset til 100 entities pr. partition — opdel hvis nødvendigt
- Throttling: ved >2000 req/sek pr. partition kan vi få 429. Backoff + retry er kun rudimentært i SDK-laget; for high-traffic features, design så vi spreader ud (fx multiple partitions)