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 af getNextSequentialId()
  • 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.jpg
  • nyborg_rideklub/Page/forside/banner.png
  • tesseracms/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)