Timeline Model
A timeline model records historical slices of data over time. It is useful for business data that depends on an effective date (for example, department structures or reports that change before/after a specific date). A business record id can have multiple slices; each slice is identified by sliceId, and effectiveStartDate/effectiveEndDate define the effective range.
1. Timeline Model Metadata
1.1 Timeline Attribute at Model Level
timeline = trueindicates this is a timeline model. It must contain the reserved fieldseffectiveStartDateandeffectiveEndDate. The system validates these fields on startup and throws an exception if missing.timeline = falseindicates a non-timeline model. Non-timeline models must not define the reserved fieldseffectiveStartDateandeffectiveEndDate.- A timeline model requires an app-generated logical id —
idStrategy = DISTRIBUTED_LONG(orDISTRIBUTED_STRING/EXTERNAL_ID).DB_AUTO_IDis rejected at boot: the auto-increment lands on the physicalsliceId, so nothing would fill the shared logicalidcolumn of a first slice (split/correct rows arrive carrying the entity’s existing id and keep it). - A timeline model must not declare
activeControl(rejected at boot):activeis an entity-level switch, while timeline storage makes every field per-slice — the combination would silently mutate the feature’s semantics and blind the interval algorithm’s neighbor probes. Express period state as a versioned business field; terminate the timeline viasetEndDate(§2.6).
1.2 Primary Keys and Fields
-
sliceId: physical primary key of a timeline model, used to update a slice. -
effectiveStartDate: effective start date of the timeline data. -
effectiveEndDate: effective end date of the timeline data. -
id: logical (business) primary key, compatible with non-timeline models. All business foreign keys referencing a timeline model use this field. -
If your database needs an auto-increment record number (such as
record_id) for change logs, you can add it yourself. It is not a framework-reserved field. -
Recommended unique constraint:
(id, effectiveStartDate, effectiveEndDate)— one index doubles as the as-of read cover (the end date is checked in-index) and as an integrity backstop: interval maintenance is a check-then-act sequence, so a true concurrent write race on one entity surfaces as a unique violation instead of silent same-start slices. Declare it with an explicitindexName(the default concatenated name exceeds the 60-char global limit for longer table names):@Index(indexName = "uk_<table>_timeline", fields = {"id", "effectiveStartDate", "effectiveEndDate"}, unique = true)
1.3 Metadata Relationships
- Timeline models can relate to themselves via One2One, Many2One, One2Many, Many2Many. Storage and references use the logical primary key
id. - When a timeline model relates to a non-timeline model, relation tables store the timeline model logical key
id. - When a non-timeline model relates to a timeline model, Many2One/One2One fields and Many2Many join tables store the timeline model logical key
id. - Association reads use
effectiveDateby default (current date if not specified), so there may be no effective slice for the current date. - In cascade query chains (for example, timeline -> non-timeline -> timeline),
effectiveDateshould be propagated to the last model to keep consistency.
1.4 Cascaded Fields
- Cascaded fields are based on Many2One/One2One associations. When the related model is a timeline model,
Context.effectiveDateis used to query the related data.
1.5 Timeline Data Concepts
- Every slice must have
effectiveStartDateandeffectiveEndDate, and slices for the sameidare expected to be continuous and non-overlapping. - To simplify queries, the last slice typically uses
effectiveEndDate = 9999-12-31. - In most cases, you only need to set
effectiveStartDate; the system computes and fillseffectiveEndDatebased on adjacent slices. - Physical record: each slice is a physical record (identified by
sliceId). Any change in effective dates creates or updates physical slices. Change logs are bound to physical records. - Logical record: a group of physical slices that share the same logical
id. Business foreign keys reference the logicalid, and association reads return the slice effective on the requested date.
Example timeline slices (same logical department id):
| sliceId (physical) | id (logical) | Department Code | Department Name | effectiveStartDate | effectiveEndDate | Manager |
|---|---|---|---|---|---|---|
| 3 | 6 | D001 | Product R&D Dept | 2022-09-01 | 9999-12-31 | Joan |
| 2 | 6 | D001 | R&D Dept | 2020-05-11 | 2022-08-31 | Tom |
| 1 | 6 | D001 | R&D Dept | 2019-08-01 | 2020-05-10 | Mars |
2. Common Scenarios
2.1 Effective Date Propagation
effectiveDateis aLocalDatestored inContext, defaulting to the current date.- Query data effective on a specific date:
effectiveStartDate <= effectiveDate && effectiveEndDate >= effectiveDate - Query data effective within a period (startDateValue, endDateValue must be non-null):
effectiveStartDate <= endDateValue && effectiveEndDate >= startDateValue - To query all slices for a business record, use
acrossTimelineData()withidfilters (or includeeffectiveStartDate/effectiveEndDatein filters). - Typical adjacent slice lookups:
previous: id = {id} AND effective_end_date = {effectiveStartDate - 1}next: id = {id} AND effective_start_date = {effectiveEndDate + 1}
2.2 read/search APIs
- Queries like
getById/getByIds/searchList/searchPagereturn only slices effective oneffectiveDateby default. - To query history across time, use
FlexQuery#acrossTimelineData()or includeeffectiveStartDate/effectiveEndDatein filters. - Cascaded reads propagate
effectiveDate. - View a record’s version list from the REST API:
/searchPage(or/searchList) with the row’sidinfiltersandacrossTimeline: truereturns all slices (each carrying its ownsliceIdand effective range). Narrow field selections on a timeline model automatically round-tripsliceId(likeversionunder optimistic locking): version rows stay actionable — correct viaupdate/ remove viadeleteBySliceId— without re-querying. TheacrossTimelineflag is the explicit half of the dual trigger, exposed onQueryParams/SearchListParams; it is not onSearchNameParams(a displayName picker wants the as-of option, not every version). WhenacrossTimelineis true,effectiveDateis ignored. Example:
POST /{model}/searchPage
{ "filters": [["id","=",6]], "orders": [["effectiveStartDate","DESC"]], "acrossTimeline": true }2.3 create APIs
- For
createOne/createList, ifeffectiveStartDateis empty, it uses the currenteffectiveDate; ifeffectiveEndDateis empty, it is set to9999-12-31. - If an existing
idis provided, the system automatically splits or adjusts adjacent slices based on the neweffectiveStartDate.
The write intents, made explicit:
| Intent | API | Key |
|---|---|---|
| Create a NEW entity | create* without id | fresh logical id + genesis slice |
| Add a version to an EXISTING entity | addVersion (or create* with the existing id) | returns the new sliceId |
| Correct one existing version | update* | keyed by sliceId (any supplied id is overwritten from the DB) |
| Terminate / reopen the timeline | setEndDate (§2.6) | keyed by logical id; writes the LAST slice’s end date |
addVersion(modelName, row)is the explicit add-version entry (REST:POST /{model}/addVersion, counterpart ofdeleteBySliceId): the row must carry the existing entity’sid, and it returns the new version’ssliceId(when the start date matches an existing slice, that slice is corrected in place and itssliceIdis returned). Fields absent from the row are copied forward from the adjacent slice, so a delta payload (id+effectiveStartDate+ changed fields) is the recommended form.addVersionAndFetchalso returns the full version row, fetched bysliceIdacross the timeline (the new version’s effective date may not be today).- Guard: a
create*call carrying anidthat matches no entity is rejected forDISTRIBUTED_LONG/STRINGmodels — a typo must not silently mint a new entity with a caller-chosen id. Exceptions:EXTERNAL_IDmodels (new entities legitimately arrive with their id) and theenableInsertIdimport mode (preset ids).
2.4 update APIs
- The current implementation uses
sliceIdas the update primary key. UpdatingeffectiveStartDateautomatically corrects adjacent slices’effectiveEndDate. effectiveEndDateis system-computed and stripped from every generic update write; the single sanctioned write path issetEndDate(§2.6). To create a new slice, usecreatewith an existingidand a neweffectiveStartDate.- If an upper layer provides a “correct”-style API (update data without creating a new slice), it should locate by
sliceId(the ORM currently does not provide a dedicated correct API).
2.5 delete / copy APIs
deleteById/deleteByIds: deletes all slices for a businessid— this is entity deletion, and it is the point where the inbound-FK delete strategy (onDeleteRESTRICT / CASCADE / SET_NULL, keyed by the logicalid) fires against referencing models.deleteBySliceId: deletes a single slice and automatically corrects adjacent slice ranges. The entity survives, soonDeletedeliberately does not fire.copyById/copyByIds: copies the current (as-of) slice into a new entity — the copyable field set excludes every structural timeline key (id/sliceId/effective dates), so the copy gets a fresh logicalidand a genesis slice at the current date. It does not duplicate the full version history, and does not add a slice to the source entity. (businessKeyfields arecopyable = false, so set a new code on the copy.)
2.6 Termination & gaps (setEndDate)
setEndDate(modelName, id, endDate)(REST:POST /{model}/setEndDate) writes theeffectiveEndDateof the entity’s LAST slice — the single sanctioned write to the system-computed end date. AnendDatebefore9999-12-31terminates the timeline: as-of reads after it return nothing. Passing9999-12-31reopens it. The tail slice is resolved server-side from the logicalid, so callers never race a stalesliceId.endDatemust not precede the tail slice’s owneffectiveStartDate— delete the trailing version(s) first (deleteBySliceId) to terminate earlier; nothing is ever implicitly discarded.- Revive: a later
addVersionwhose start is after a terminated end date inserts a fresh open segment, deliberately leaving a gap. Routing falls out of the existing algorithm — no special cases:
addVersion start lands… | Behavior |
|---|---|
| inside existing coverage | normal split / same-start correct; a terminated end date survives the split |
| inside a gap, before a later segment | fills forward: new slice ends one day before the next segment’s start |
| after everything (terminated tail) | revives: fresh segment open to 9999-12-31; the gap stays |
- Gaps are safe, silent, and deliberate. A gap is “no coverage”: as-of reads inside it return no row — exactly the state a not-yet-effective entity (future-dated genesis) already produces, so consumers carry no new obligation. Overlaps — the actual corruption class (two rows for one date) — remain constructively impossible: only
setEndDatewrites an end date, and only on the tail, which has no right neighbor. Gaps have no first-class row, so “why is it dark” lives only in the changelog; a domain that needs a queryable reason (suspended vs terminated, reporting rows) should model a versioned status field on top — the two compose. - Termination is not deletion: the entity
idstays valid, inbound FKs keep resolving (as-of joins simply return nothing past the end date), and theonDeletestrategy does not fire. - Sharp edges (the generic interval rules applied at a termination boundary — visible in the version list, one
setEndDateaway from repair): a revived segment created with no neighbor copies nothing (genesis-like — provide required fields); moving a revived segment’s start left onto the terminated segment re-derives that end date (the gap is bridged); deleting the slice that carries the terminated end date transfers it to the predecessor (the heal rule), rather than reopening.
2.7 Versioning seam (engine internals)
- All timeline handling in
ModelServiceImplroutes through oneVersioningStrategyseam (service/versioning/):IdentityStrategyis a no-op for regular models,TimelineStrategyadapts the interval-maintenance algorithm inTimelineService. New read paths must route Filters/FlexQuery through thescopedReadexits — there is no per-call-siteif (isTimelineModel)to forget. - The across-timeline opt-out is a dual trigger by contract: the explicit
FlexQuery.acrossTimelineData()flag (also set from REST viaQueryParams/SearchListParams.acrossTimeline), or caller-suppliedeffectiveStartDate/effectiveEndDateconditions (which declare “I am doing my own temporal filtering”). Either suppresses the default effective-date clamp; both are intended, stable behavior. - Accepted limitations (a master-detail table split was evaluated and rejected — its headline benefit, a real DB FK target, is moot because referential integrity is enforced app-level and no physical FKs are emitted): version-invariant fields (e.g.
code) repeat on every slice, and a declarative reference-by-code relation to a timeline model is not supported (codeis not physically unique across slices). Reference timeline entities by logicalid(as-of) or pin one slice viasliceId; a runtime “code+ effective date” as-of query is fully supported (non-overlapping intervals make it unique). Context.effectiveDateis ambient state (defaults to today). Batch engines that fan work out across threads must propagate the context (ScopedValue) to workers — e.g. a payroll run pricing bypayDate— or that branch silently prices “as of today”.
2.8 search Join Rules for Timeline Associations
- When the related object is a timeline model, Many2One/One2One queries automatically append to the
LEFT JOIN ONclause:effectiveStartDate <= effectiveDate AND effectiveEndDate >= effectiveDate. - One2Many/Many2Many cascades also filter slices based on
effectiveDate.
Examples
1) Model Definition
@Data
@EqualsAndHashCode(callSuper = true)
@Model(label = "Product Price", timeline = true, idStrategy = IdStrategy.DISTRIBUTED_LONG)
@Index(indexName = "uk_product_price_timeline",
fields = {"id", "effectiveStartDate", "effectiveEndDate"}, unique = true)
public class ProductPrice extends TimelineModel {
@Serial
private static final long serialVersionUID = 1L;
@Field(label = "ID")
private Long id;
@Field(label = "Product ID")
private Long productId;
@Field(label = "Price") // BigDecimal → DECIMAL(32,8) by default (money)
private BigDecimal price;
}2) Query Current and Historical Slices
ContextHolder.getContext().setEffectiveDate(LocalDate.of(2025, 1, 1));
Filters filters = new Filters().eq("productId", 1001L);
List<Map<String, Object>> current = modelService.searchList("ProductPrice", new FlexQuery(filters));
FlexQuery historyQuery = new FlexQuery(new Filters().eq("id", 1L))
.acrossTimelineData()
.orderBy(Orders.ofAsc("effectiveStartDate"));
List<Map<String, Object>> history = modelService.searchList("ProductPrice", historyQuery);3) REST: full version list for one record
POST /ProductPrice/searchPage
{
"filters": [["id", "=", 1]],
"orders": [["effectiveStartDate", "ASC"]],
"acrossTimeline": true
}3. Performance
- By default, queries do not scan across time (no
effectiveStartDate/effectiveEndDatefilters and noacrossTimelineData()), which reduces scanning. - Add indexes for
effectiveStartDateandeffectiveEndDate.
4. Time-Effective (Non-Timeline) Data
Some models need history records with effective dates but are not timeline models (for example, HR changes, work history, education history). These cases may allow multiple records on the same day and do not require continuous slices.
In Softa, timeline fields are reserved. If you need history-only behavior, use a separate history model or different field names, and keep timeline = false to avoid timeline slice semantics.