Compare commits

...

8 Commits

Author SHA1 Message Date
Marcel
3a7c86fc87 test(timeline): allow timeline package in entity-location ArchRule
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m9s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m42s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
The entities_reside_in_domain_packages ArchUnit rule has a hardcoded
allow-list of domain packages; add ..timeline.. so TimelineEvent passes.
CI caught this — the new domain package was not yet whitelisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
1226bd0a07 docs(timeline): register timeline domain in package tables and diagrams
Add timeline/ to the root and backend package tables, TimelineEvent to the
domain-model entity tables, TimelineEvent/EventType/Zeitstrahl to the
glossary, a new l3-backend-timeline C4 component diagram, and the
timeline_events table + two join tables (with their CHECKs and cascade FKs)
to the db-orm and db-relationships ER diagrams. Bumps the db-orm snapshot to
V77.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
00a00b2c87 docs(adr): add ADR-040 timeline domain data model
Records the architectural commitment for the timeline domain: views-not-
entities for issue 3 (ADR-036 rationale), DatePrecision import coupling
(ADR-025), the UNKNOWN-forbidden / SEASON-APPROX-legal precision contract,
the strict biconditional RANGE CHECK as a deliberate divergence from
Document, the @Version + NOT NULL audit-trail decisions, the optimistic-
lock-to-conflict translation contract (CWE-209), the server-populated-only
createdBy/updatedBy forgery guard (CWE-639), and the EventType stable
frontend styling contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
cc841a7a4c test(timeline): cover persistence, constraints, and FK cascade
@DataJpaTest against real Postgres (never H2): required-field round-trip,
YEAR default, linked persons/documents, eventDateEnd null/range round-trip,
TEXT description with no length cap, both RANGE-invariant rejections, the
UNKNOWN-precision rejection (NOT_SUPPORTED so the constraint violation does
not poison the test transaction), version null-before-persist/0-after-save,
and a parameterized accept-side proving DAY/MONTH/SEASON/YEAR/APPROX all
persist. makeEvent() defaults createdBy/updatedBy to random UUIDs so every
red is red for the intended reason.

@SpringBootTest cascade guard: deleting a linked Person/Document via the
domain service drops the join row (verified by direct COUNT) and leaves the
event intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
513cdb7a4d feat(timeline): add V77 migration for timeline_events table
Creates timeline_events plus the timeline_event_persons and
timeline_event_documents join tables, all FK columns ON DELETE CASCADE
(a person/document delete drops the join row, the event survives —
V71-class hardening). Two CHECK constraints push integrity to Postgres:
chk_timeline_event_range enforces event_date_end non-null IFF RANGE (a
strict biconditional, intentionally tighter than Document's open-ended
ranges), and chk_timeline_event_precision forbids exactly UNKNOWN while
keeping SEASON/APPROX legal. FK and query-column indexes added up-front
to avoid the V62 retrofit debt. Forward-only, additive DDL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
595007213c feat(timeline): add TimelineEvent entity and repository
Curated timeline event mirroring Document's date block (eventDate /
precision / eventDateEnd) so events and letters share one rendering path.
Audit footprint deliberately diverges from Document: @Version optimistic
lock plus NOT NULL createdBy/updatedBy for the multi-curator edit flow.
precision reuses document.DatePrecision (imported, not duplicated) and
defaults to YEAR. ManyToMany persons/documents with explicit @JoinTable +
@BatchSize, matching Document's join conventions.

Repository is empty for now with a TODO marker for the issue-5 per-person
filter query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
45001f042a feat(timeline): add EventType enum
PERSONAL/HISTORICAL classify a curated timeline event. The string value
names are a stable frontend styling contract (family vs. muted world
accent) — no mapping layer; renaming requires a coordinated frontend
change. First piece of the new timeline domain (Zeitstrahl, issue #774).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
d11378c254 fix(deps): pin esbuild 0.28.1 and cookie >=0.7.0 to clear npm audit gate
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m46s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m42s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
CI / Unit & Component Tests (push) Successful in 5m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 6m4s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m12s
nightly / deploy-staging (push) Successful in 5m20s
The CI step "Security audit (no dev deps)" (npm audit --audit-level=high
--omit=dev) failed repo-wide on every PR: newly-published advisories landed
against the already-pinned esbuild 0.27.7 (GHSA-gv7w-rqvm-qjhr,
GHSA-g7r4-m6w7-qqqr — both high), pulled in transitively via
vite -> @sveltejs/vite-plugin-svelte. The fix for both only exists at
esbuild@0.28.1. A scoped vite@7 minor bump cannot help — all vite 7.x pin
esbuild ^0.27.0.

Add an overrides block:
  - esbuild 0.28.1 (exact, no caret — a future 0.29.x must not silently
    float in and re-break vite; let Renovate propose bumps explicitly)
  - cookie >=0.7.0 (clears the low GHSA-pxg6-pf52-xh8x reaching the prod
    tree via @sentry/sveltekit; drop-in, done in the same pass)

npm audit --audit-level=high --omit=dev now exits 0 with 0 vulnerabilities.
npm run build, lint, and a dev-server boot all succeed with the forced
esbuild 0.28.1 (validated, not assumed — it sits outside vite@7.3.3's
declared ^0.27.0 range).

Closes #817

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:23:31 +02:00
16 changed files with 840 additions and 114 deletions

View File

@@ -95,6 +95,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ User domain — AppUser, UserGroup, UserService
```
@@ -115,6 +116,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

@@ -42,6 +42,7 @@ src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ # PersonRelationship sub-domain
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ # Tag domain — Tag, TagService, TagController
├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ # User domain — AppUser, UserGroup, UserService
```
@@ -67,6 +68,7 @@ For per-domain ownership and public surface, see each domain's `README.md`.
| `Comment` | `document_comments` | Threaded comments with mentions |
| `Notification` | `notifications` | User notification feed |
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
| `TimelineEvent` | `timeline_events` | Curated Zeitstrahl event; ManyToMany persons + documents (join FKs ON DELETE CASCADE); `@Version` + NOT NULL createdBy/updatedBy |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.timeline;
/**
* Kind of a curated {@link TimelineEvent}.
*
* <p>The string value names are a <strong>stable frontend styling contract</strong>: the
* Svelte timeline components hard-code {@code "PERSONAL"} (family accent) and
* {@code "HISTORICAL"} (muted world accent) as Tailwind class-map keys. There is no
* mapping layer — renaming either value requires a coordinated frontend change. See
* ADR-040.
*/
public enum EventType {
/** A family/personal event (birth, wedding, move) — rendered with the family accent. */
PERSONAL,
/** A world/historical event providing context — rendered with the muted world accent. */
HISTORICAL
}

View File

@@ -0,0 +1,129 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* A curated event on the family timeline (Zeitstrahl). Unlike a {@link Document}, which is
* OCR-derived, a {@code TimelineEvent} is authored by curators — hence the optimistic-lock
* {@link #version} and the {@link #createdBy}/{@link #updatedBy} audit trail that
* {@code Document} lacks.
*
* <p>The date block ({@link #eventDate}, {@link #precision}, {@link #eventDateEnd}) mirrors
* {@code Document}'s so events and letters share one rendering path. The mirror applies to
* the date block only — the audit footprint deliberately diverges (see ADR-040).
*/
@Entity
@Table(name = "timeline_events")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TimelineEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String title;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private EventType type;
/** The most precise date known for the event. Always present — a curated event is never undated. */
@Column(name = "event_date", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate eventDate;
/**
* Precision of {@link #eventDate}. Reuses {@code document.DatePrecision} (one rendering
* path; see ADR-025 / ADR-040). Every value except {@code UNKNOWN} is legal for a curated
* event — including {@code SEASON} ("Sommer 1914") and {@code APPROX} ("ca. 1914"). The DB
* CHECK forbids exactly {@code UNKNOWN}; do not narrow it further.
*/
@Enumerated(EnumType.STRING)
@Column(name = "date_precision", nullable = false, length = 16)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private DatePrecision precision = DatePrecision.YEAR;
/** Range end — non-null <strong>iff</strong> {@link #precision} is {@code RANGE} (DB CHECK, both directions). */
@Column(name = "event_date_end")
private LocalDate eventDateEnd;
@Column(columnDefinition = "TEXT")
private String description;
/** People the event involves. */
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "timeline_event_persons",
joinColumns = @JoinColumn(name = "timeline_event_id"),
inverseJoinColumns = @JoinColumn(name = "person_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Person> persons = new HashSet<>();
/** Optional supporting letters linked to the event. */
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "timeline_event_documents",
joinColumns = @JoinColumn(name = "timeline_event_id"),
inverseJoinColumns = @JoinColumn(name = "document_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Document> documents = new HashSet<>();
/**
* UUID of the {@code AppUser} who created the event. Bare UUID, no FK to {@code app_users}
* (sidecar pattern — keeps {@code timeline} decoupled from {@code user}). Server-populated
* from the session principal; never accepted from client input (authorship-forgery vector,
* CWE-639 — see ADR-040).
*/
@Column(name = "created_by", nullable = false)
private UUID createdBy;
@CreationTimestamp
private LocalDateTime createdAt;
/**
* UUID of the {@code AppUser} who last edited the event. Populated from the session
* principal in {@code TimelineEventService}; <strong>must be set before every
* {@code save()}</strong> — {@code @UpdateTimestamp} on {@link #updatedAt} does NOT set this
* automatically, so without an explicit set the timestamp advances while the "who" goes
* stale. Same forgery rationale as {@link #createdBy}.
*/
@Column(name = "updated_by", nullable = false)
private UUID updatedBy;
@UpdateTimestamp
private LocalDateTime updatedAt;
/**
* Optimistic-lock version for the multi-curator edit flow (issue 3). Object {@code Long}
* (not primitive) so it is {@code null} before first persist; Hibernate sets {@code 0} on
* insert. A concurrent-write conflict must be translated to {@code DomainException.conflict}
* in the service layer (ADR-040) — otherwise it surfaces as HTTP 500 with Hibernate
* internals (CWE-209).
*/
@Version
private Long version;
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.timeline;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface TimelineEventRepository extends JpaRepository<TimelineEvent, UUID> {
// TODO(issue 5): findByPersonsContaining(Person) needed for the per-person filter
}

View File

@@ -0,0 +1,65 @@
-- V77: timeline domain foundation (Zeitstrahl) — curated timeline events.
-- Forward-only, additive DDL. No rollback script: rollback requires manual DROP TABLE
-- (timeline_event_documents, timeline_event_persons, then timeline_events). See ADR-040.
--
-- The date block (event_date / date_precision / event_date_end) mirrors documents' so events
-- and letters share one rendering path. Two divergences from documents are INTENTIONAL and
-- enforced here in Postgres (ADR-040):
-- 1. The RANGE rule is a strict biconditional (event_date_end non-null IFF RANGE), unlike
-- documents' open-ended ranges — a curated event always has a known, closed end.
-- 2. date_precision <> 'UNKNOWN' — only OCR-inferred letters are ever undated; a curated
-- event always has at least a year. SEASON and APPROX stay legal (Sommer/ca. 1914).
CREATE TABLE timeline_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
type VARCHAR(16) NOT NULL,
event_date DATE NOT NULL,
date_precision VARCHAR(16) NOT NULL DEFAULT 'YEAR',
event_date_end DATE,
description TEXT,
created_by UUID NOT NULL,
created_at TIMESTAMP,
updated_by UUID NOT NULL,
updated_at TIMESTAMP,
version BIGINT,
CONSTRAINT pk_timeline_events PRIMARY KEY (id),
-- event_date_end is non-null IFF precision is RANGE (both directions).
CONSTRAINT chk_timeline_event_range
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL)),
-- Curated events are never undated. Forbids exactly UNKNOWN — every other
-- DatePrecision value (DAY, MONTH, SEASON, YEAR, RANGE, APPROX) stays legal.
CONSTRAINT chk_timeline_event_precision
CHECK (date_precision <> 'UNKNOWN')
);
-- Join table: events ↔ persons involved.
CREATE TABLE timeline_event_persons (
timeline_event_id UUID NOT NULL,
person_id UUID NOT NULL,
CONSTRAINT pk_timeline_event_persons PRIMARY KEY (timeline_event_id, person_id),
CONSTRAINT fk_tep_event
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
CONSTRAINT fk_tep_person
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
);
-- Join table: events ↔ supporting letters.
CREATE TABLE timeline_event_documents (
timeline_event_id UUID NOT NULL,
document_id UUID NOT NULL,
CONSTRAINT pk_timeline_event_documents PRIMARY KEY (timeline_event_id, document_id),
CONSTRAINT fk_ted_event
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
CONSTRAINT fk_ted_document
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
-- Indexes added up-front (avoid the V62 FK-index retrofit debt): the two query columns plus
-- explicit indexes on all four FK columns.
CREATE INDEX idx_timeline_events_event_date ON timeline_events (event_date);
CREATE INDEX idx_timeline_events_type ON timeline_events (type);
CREATE INDEX idx_timeline_event_persons_person_id ON timeline_event_persons (person_id);
CREATE INDEX idx_timeline_event_persons_event_id ON timeline_event_persons (timeline_event_id);
CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id);
CREATE INDEX idx_timeline_event_documents_event_id ON timeline_event_documents (timeline_event_id);

View File

@@ -122,7 +122,8 @@ class ArchitectureTest {
.that().areAnnotatedWith(Entity.class)
.should().resideInAnyPackage(
"..document..", "..person..", "..tag..", "..user..",
"..geschichte..", "..notification..", "..ocr..", "..audit.."
"..geschichte..", "..notification..", "..ocr..", "..audit..",
"..timeline.."
);
// TODO Rule 5: Controllers expose endpoints under their domain prefix

View File

@@ -0,0 +1,95 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves V77's FK {@code ON DELETE CASCADE} on the join tables: deleting a linked Person or
* Document drops the join row and leaves the {@link TimelineEvent} intact (a person/document
* delete must never 500 — V71-class regression guard). Needs the full Spring context for
* {@link PersonService}/{@link DocumentService}, mirroring {@code PersonServiceIntegrationTest}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineEventCascadeIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineEventRepository events;
@Autowired PersonRepository personRepository;
@Autowired PersonService personService;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentService documentService;
@Autowired JdbcTemplate jdbc;
@PersistenceContext EntityManager em;
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void deleting_linked_person_keeps_event_and_drops_join_row() {
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent event = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
personService.deletePerson(anna.getId());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
@Test
void deleting_linked_document_keeps_event_and_drops_join_row() {
Document letter = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent event = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
documentService.deleteDocument(letter.getId(), UUID.randomUUID());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_documents WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
}

View File

@@ -0,0 +1,202 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Persistence + DB-constraint tests for {@link TimelineEvent} against real Postgres (V77).
* Mirrors {@code MigrationIntegrationTest}'s slice setup; never H2 — only the real DB proves
* enum-as-varchar storage, the RANGE/UNKNOWN CHECK constraints, FK cascade, and {@code @Version}.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TimelineEventTest {
@Autowired TimelineEventRepository events;
@Autowired PersonRepository persons;
@Autowired DocumentRepository documents;
@Autowired EntityManager em;
/**
* Sensible defaults; each test overrides only what it asserts. {@code createdBy}/{@code updatedBy}
* default to random UUIDs — both columns are NOT NULL and not auto-populated, so without these
* every test would fail at flush with the same constraint violation (red for the wrong reason).
* Precision is intentionally left unset so {@code @Builder.Default YEAR} can be exercised.
*/
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void persists_and_loads_event_with_required_fields() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getId()).isNotNull();
}
@Test
void precision_defaults_to_YEAR_when_not_set() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getPrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void persists_event_with_linked_persons() {
Person anna = persons.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent saved = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getPersons()).extracting(Person::getId).containsExactly(anna.getId());
}
@Test
void persists_event_with_linked_documents() {
Document letter = documents.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent saved = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getDocuments()).extracting(Document::getId).containsExactly(letter.getId());
}
@Test
void eventDateEnd_round_trips_null_for_non_range() {
TimelineEvent saved = events.save(makeEvent().build()); // YEAR precision, no end
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()).isNull();
}
@Test
void eventDateEnd_round_trips_value_for_range() {
TimelineEvent saved = events.save(makeEvent()
.precision(DatePrecision.RANGE)
.eventDate(LocalDate.of(1914, 1, 1))
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd())
.isEqualTo(LocalDate.of(1918, 12, 31));
}
@Test
void description_round_trips_null() {
TimelineEvent saved = events.save(makeEvent().build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isNull();
}
@Test
void description_round_trips_multi_kb_text() {
// Proves TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides
// Hibernate's default VARCHAR(255).
String longText = "Sommertage am See. ".repeat(500); // ~9.5 KB
TimelineEvent saved = events.save(makeEvent().description(longText).build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isEqualTo(longText);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_non_null_end_without_range_precision() {
// precision YEAR + non-null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll(); // NOT_SUPPORTED opts out of the rollback; clean any leaked row
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_range_precision_without_end_date() {
// precision RANGE + null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.RANGE)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void unknown_precision_is_rejected() {
// chk_timeline_event_precision forbids UNKNOWN — curated events are never undated.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.UNKNOWN)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
void version_is_null_before_persist_and_zero_after_save() {
TimelineEvent fresh = makeEvent().build();
assertThat(fresh.getVersion()).isNull(); // @Version Long is null pre-persist
TimelineEvent saved = events.saveAndFlush(fresh);
assertThat(saved.getVersion()).isEqualTo(0L); // Hibernate sets 0 on insert
}
@ParameterizedTest
@EnumSource(value = DatePrecision.class, names = {"DAY", "MONTH", "SEASON", "YEAR", "APPROX"})
void all_non_unknown_precisions_are_accepted(DatePrecision precision) {
// Accept-side of chk_timeline_event_precision: every non-RANGE, non-UNKNOWN value persists.
// Documents that SEASON ("Sommer 1914") and APPROX ("ca. 1914") are intentionally legal,
// so an over-tight CHECK cannot ship green. (RANGE is covered by the round-trip test.)
TimelineEvent saved = events.saveAndFlush(makeEvent().precision(precision).build());
assertThat(saved.getId()).isNotNull();
}
}

View File

@@ -164,6 +164,12 @@ _Not to be confused with a document item's optional note_ — a document item's
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
**TimelineEvent** (`TimelineEvent`, table `timeline_events`) `[internal]` — a curated event on the family timeline (*Zeitstrahl*), authored by a curator rather than OCR-derived. Carries the same date block as a `Document` (`eventDate` + `precision` + nullable `eventDateEnd`) so events and letters render through one path, plus a `title`, optional `description`, an `EventType`, and `ManyToMany` links to the `Person`s it involves and the `Document`s that support it (both join FKs `ON DELETE CASCADE`). Diverges from `Document` with an optimistic-lock `@Version` and a NOT NULL `createdBy`/`updatedBy` audit trail (bare UUIDs, no FK to `app_users`) for the multi-curator edit flow. Two DB CHECKs: `event_date_end` is non-null **iff** precision is `RANGE` (a strict biconditional, intentionally tighter than `Document`'s open-ended ranges), and `precision` is never `UNKNOWN` (a curated event always has at least a year; `SEASON`/`APPROX` stay legal). See ADR-040.
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
**Audit log** (`AuditLog`, table `audit_log`) — an append-only event store recording domain-level activity (document edits, user actions, etc.). Append-only by application convention; a `REVOKE UPDATE, DELETE` is attempted at the DB layer (see migrations V46, V47) but is a no-op if the application role is the table owner in PostgreSQL. Do not rely on DB-enforced immutability — the constraint is application-layer only.

View File

@@ -0,0 +1,112 @@
# ADR-040 — Timeline domain data model
**Status:** Accepted
**Date:** 2026-06-12
**Issue:** #774 (Zeitstrahl milestone, foundational)
## Context
The Zeitstrahl (family timeline) needs a home for *curated* events — births,
weddings, moves, and world-historical context a curator types in by hand, distinct
from the OCR-derived `Document` letters. This ADR commits the new `timeline` domain's
data model: the `TimelineEvent` entity, the `EventType` enum, a repository, and the
V77 migration. No service, controller, or DTO ships here — those land in later issues.
A `TimelineEvent` carries the same date block as `Document` (`eventDate` +
`precision` + `eventDateEnd`) so events and letters render through one path, but its
audit footprint deliberately diverges (see below).
## Decisions
### 1. New `timeline` domain package, separate from `geschichte`
Curated timeline events are their own concern, not a Lesereise/`Geschichte` subtype.
The domain owns `TimelineEvent`, `EventType`, and `TimelineEventRepository`.
### 2. Responses (issue 3) will be views, not serialized entities
`TimelineEvent` has two LAZY `ManyToMany` collections (`persons`, `documents`) and
`open-in-view` is `false` — exactly the shape that motivated ADR-036 for `geschichte`.
Issue 3 must assemble `TimelineEventView`/`TimelineEventSummary` inside the service
transaction; a serialized entity is a 500 waiting to happen. Decided up front so it is
not retrofitted later.
### 3. `precision` reuses `document.DatePrecision` — imported, not duplicated
The `timeline` package imports `org.raddatz.familienarchiv.document.DatePrecision`
directly, the same cross-domain value-type sharing ADR-039 established for `person`.
An enum has no behaviour and no persistence side effects, so the layering rule
(services → own repository) does not govern it. The enum stays a verbatim mirror of the
import normalizer's `Precision` values (ADR-025); changes must stay in sync with
`tools/import-normalizer/dates.py`. Moving `DatePrecision` into a shared package is a
wider refactor (touching `Document`, `importing`, `person`) and its own future ADR.
### 4. `precision = UNKNOWN` is forbidden; every other value is legal
`eventDate` is NOT NULL — a curated event always has at least a year, so only OCR letters
fall into the "Ohne Datum" bucket. The CHECK `chk_timeline_event_precision`
(`date_precision <> 'UNKNOWN'`) forbids exactly that one value. `SEASON` ("Sommer 1914")
and `APPROX` ("ca. 1914") are explicitly legal — family memory is full of both, and the
spec's rendering table covers them. Do not narrow the CHECK to an allow-list; an
over-tight constraint would force curators to fake `YEAR` and render dishonest dates.
### 5. RANGE invariant is a strict biconditional at the DB, intentionally tighter than `Document`
`chk_timeline_event_range` enforces `(date_precision = 'RANGE') = (event_date_end IS NOT
NULL)``eventDateEnd` is non-null **iff** precision is `RANGE`, both directions. This is
*stricter* than `Document`'s open-ended ranges (which allow a null end on a RANGE) because a
curated event always has a known, closed end when it spans a range — it is authored, not
inferred. This divergence is deliberate: a future "bug fix" must not relax it to match
`Document`.
### 6. Audit trail: `@Version` + NOT NULL `createdBy`/`updatedBy`, diverging from `Document`
`Document` has neither a version nor a creator. A curated entity edited by multiple curators
warrants real protection, so `TimelineEvent` adds:
- `@Version Long version` — optimistic locking for the multi-curator edit flow (issue 3).
Object `Long` (not primitive) so it is `null` before first persist; Hibernate sets `0` on
insert. The service **must** catch `ObjectOptimisticLockingFailureException` and translate
it to `DomainException.conflict(...)`. Without that translation a concurrent-write conflict
surfaces as HTTP 500 with Hibernate internals in the body — information disclosure (CWE-209).
- `createdBy`/`updatedBy` as bare `UUID`, `NOT NULL`, no FK to `app_users` (sidecar pattern,
matching `DocumentAnnotation`/`OcrJob`; keeps `timeline` decoupled from `user`, avoids
lazy-load surprises in the read-heavy assembly path). NOT NULL makes a curated event with no
author impossible — an audit gap closed at the schema level. `DocumentAnnotation.createdBy`
is nullable and has no `updatedBy`; the escalation here is deliberate because curated events
are multi-author. Curator display names resolve through `UserService` at render time.
`updatedBy` is **not** advanced by `@UpdateTimestamp` — the service must set it from the
session principal before every `save()`, or the timestamp moves while the "who" goes stale.
### 7. `createdBy`/`updatedBy` are server-populated only — never bound from client input
Both are set from the session principal in the service, never from a request body. Binding
them from client input is an authorship-forgery / mass-assignment vector (CWE-639). Issue 3's
regression suite must include forgery cases on **both** write paths (`POST` body with
`createdBy`, `PUT` body with `updatedBy`) — create and update are separate binding paths, so
testing only one leaves half the vector open. The update test must assert `updatedBy` equals
the *second* editor's UUID, not merely non-null.
### 8. `EventType` string values are a stable frontend styling contract
The Tailwind class map in the timeline Svelte components hard-codes `PERSONAL` (family accent)
and `HISTORICAL` (muted world accent) as strings. There is no mapping layer — renaming either
value requires a coordinated frontend change. Recorded here to prevent a silent regression.
### 9. Explicit `@JoinTable` on both ManyToMany fields
Without explicit `@JoinTable(name, joinColumns, inverseJoinColumns)`, Hibernate's naming
strategy could diverge from the V77 DDL's explicit table/column names. Explicit mapping
guarantees alignment and makes future column renames a deliberate, visible change. All four FK
columns are `ON DELETE CASCADE`: deleting a Person or Document drops the join row and leaves
the event intact (V71/ADR-032 hardening — a person delete must never 500).
## Consequences
- V77 is forward-only; rollback is manual DDL (`DROP TABLE` the two join tables, then
`timeline_events`). No rollback script, no rollback test.
- The `timeline → document.DatePrecision` compile coupling is permanent until a shared-package
refactor; precedent already exists (`importing/DocumentImporter`, `person`).
- The service/controller/DTO layer (issue 3) inherits the view-assembly, optimistic-lock
translation, forgery-guard, and permission obligations recorded above.

View File

@@ -0,0 +1,24 @@
@startuml
!include <C4/C4_Component>
title Component Diagram: API Backend — Timeline (Zeitstrahl)
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in a later issue.")
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, issue 3)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.")
Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, issue 3)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
}
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves")
Rel(timelineRepo, db, "SQL queries", "JDBC")
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)")
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)")
Rel(timelineRepo, personDomain, "References persons via join table")
Rel(timelineRepo, documentDomain, "References documents via join table")
@enduml

View File

@@ -1,6 +1,6 @@
@startuml db-orm
' Schema source: Flyway V1V76 (excl. V37, V43 — intentionally removed)
' Schema as of: V76 (2026-06-12)
' Schema source: Flyway V1V77 (excl. V37, V43 — intentionally removed)
' Schema as of: V77 (2026-06-12)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle
@@ -386,6 +386,39 @@ package "Supporting" {
}
}
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events {
id : UUID <<PK>>
--
title : VARCHAR(255) NOT NULL
type : VARCHAR(16) NOT NULL
event_date : DATE NOT NULL
date_precision : VARCHAR(16) NOT NULL DEFAULT 'YEAR'
event_date_end : DATE
description : TEXT
created_by : UUID NOT NULL
created_at : TIMESTAMP
updated_by : UUID NOT NULL
updated_at : TIMESTAMP
version : BIGINT
==
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL))
CHECK (date_precision <> 'UNKNOWN')
}
entity timeline_event_persons {
timeline_event_id : UUID <<FK>>
person_id : UUID <<FK>>
}
entity timeline_event_documents {
timeline_event_id : UUID <<FK>>
document_id : UUID <<FK>>
}
}
' Auth relationships
app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_id
@@ -449,4 +482,10 @@ geschichten_persons }o--|| persons : person_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
' Timeline relationships
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
@enduml

View File

@@ -6,6 +6,7 @@
' precision/attribution fields); no new FK relationships, so this diagram is unchanged.
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
' precision columns; columns only, no new FK relationships, diagram unchanged.
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
hide circle
skinparam linetype ortho
@@ -71,6 +72,13 @@ package "Supporting" {
entity journey_items
}
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events
entity timeline_event_persons
entity timeline_event_documents
}
' Auth relationships
app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_id
@@ -136,4 +144,11 @@ journey_items }o--o| documents : document_id (ON DELETE SET NULL)
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
' Timeline relationships (V77)
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
note right of timeline_events : CHECK event_date_end non-null IFF RANGE\nCHECK date_precision <> 'UNKNOWN' (V77)
@enduml

View File

@@ -558,9 +558,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -574,9 +574,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -590,9 +590,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -606,9 +606,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -622,9 +622,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -638,9 +638,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -654,9 +654,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -670,9 +670,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -686,9 +686,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -702,9 +702,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -718,9 +718,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -734,9 +734,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -750,9 +750,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -766,9 +766,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -782,9 +782,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -798,9 +798,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -814,9 +814,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -830,9 +830,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -846,9 +846,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -862,9 +862,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -878,9 +878,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -894,9 +894,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -910,9 +910,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -926,9 +926,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -942,9 +942,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -958,9 +958,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -5531,12 +5531,16 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
@@ -5799,9 +5803,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -5811,32 +5815,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {

View File

@@ -73,5 +73,9 @@
"vite-plugin-static-copy": "^4.1.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"
},
"overrides": {
"esbuild": "0.28.1",
"cookie": ">=0.7.0"
}
}