Reladynamo · storage layout

Your table on DynamoDB

The same bitemporal rows twice: as the relational table Reladomo already knows, and as the DynamoDB items Reladynamo writes, byte for byte.


One row, one item

Reladynamo does not reshape your data. Each Reladomo object gets its own table, each row becomes one item, and each column becomes one attribute with the same name. DynamoDB also needs an address for every item, a partition key and a sort key, and Reladynamo builds both from values that are already in the row.

Nothing is summarised, nested or denormalised. If you can read the SQL table, you can read the items. The key is the only new thing to learn.


The table you have

Take the Balance object from the README. Two AsOfAttributes make it bitemporal, and the XML names every column:

<AsOfAttribute name="businessDate"   fromColumnName="FROM_Z" toColumnName="THRU_Z" … isProcessingDate="false"
               futureExpiringRowsExist="true" …/>
<AsOfAttribute name="processingDate" fromColumnName="IN_Z"   toColumnName="OUT_Z"  … isProcessingDate="true"  …/>
<Attribute name="balanceId" javaType="int"    columnName="BALANCE_ID" primaryKey="true"/>
<Attribute name="quantity"  javaType="double" columnName="QUANTITY"/>

The README's snippet leaves futureExpiringRowsExist off; this page sets it, because it changes the rows below. Without it, when Reladomo closes an open-ended row that has already started it also cuts that row's THRU_Z back to the transaction time plus one day (GenericBiTemporalDirector.inactivateObject), so row A would carry THRU_Z 2026-06-16 14:02:31.250 instead of infinity. The CRM demo's Customer omits the flag, and its history shows exactly that.

A balance of 10 is entered on 5 January, true from 1 January. On 15 June someone learns it should have been 99 from 1 March. Reladomo's TemporalDirector, not the adapter, leaves three rows behind:

RowBALANCE_IDQUANTITYFROM_ZTHRU_ZIN_ZOUT_Z
A4210.02026-01-01
00:00:00.000
9999-12-01
23:59:00.000
2026-01-05
09:30:00.000
2026-06-15
14:02:31.250
B4210.02026-01-01
00:00:00.000
2026-03-01
00:00:00.000
2026-06-15
14:02:31.250
9999-12-01
23:59:00.000
C4299.02026-03-01
00:00:00.000
9999-12-01
23:59:00.000
2026-06-15
14:02:31.250
9999-12-01
23:59:00.000
identity business time: when it was true processing time: when we knew

9999-12-01 23:59:00.000 is Reladomo's infinity. Row A is the original belief, closed at the moment the correction was recorded. B and C are what is believed now.


The same three rows, as items

This is what DynamoDbWriter puts in table BALANCE for each row. It is shown in DynamoDB's JSON wire format, in the order the writer assembles the item, with each column lined up against the attribute it became.

A · the January belief, closed in June

Relational row

-- row A
-- no column
BALANCE_ID  42
QUANTITY    10.0
FROM_Z      2026-01-01 00:00:00.000
THRU_Z      9999-12-01 23:59:00.000
IN_Z        2026-01-05 09:30:00.000
OUT_Z       2026-06-15 14:02:31.250
-- built from BALANCE_ID
-- built from IN_Z, FROM_Z

DynamoDB item

{
  "_rd_v":      {"N": "1"},
  "BALANCE_ID": {"N": "42"},
  "QUANTITY":   {"B": "QCQAAAAAAAA="},
  "FROM_Z":     {"S": "20260101000000000"},
  "THRU_Z":     {"S": "99991201235900000"},
  "IN_Z":       {"S": "20260105093000000"},
  "OUT_Z":      {"S": "20260615140231250"},
  "pk":         {"S": "v1#BALANCE#42"},
  "sk":         {"S": "v1#P#20260105093000000#B#20260101000000000"}
}

B · 10 from January to March, known since June

Relational row

-- row B
-- no column
BALANCE_ID  42
QUANTITY    10.0
FROM_Z      2026-01-01 00:00:00.000
THRU_Z      2026-03-01 00:00:00.000
IN_Z        2026-06-15 14:02:31.250
OUT_Z       9999-12-01 23:59:00.000
-- built from BALANCE_ID
-- built from IN_Z, FROM_Z

DynamoDB item

{
  "_rd_v":      {"N": "1"},
  "BALANCE_ID": {"N": "42"},
  "QUANTITY":   {"B": "QCQAAAAAAAA="},
  "FROM_Z":     {"S": "20260101000000000"},
  "THRU_Z":     {"S": "20260301000000000"},
  "IN_Z":       {"S": "20260615140231250"},
  "OUT_Z":      {"S": "99991201235900000"},
  "pk":         {"S": "v1#BALANCE#42"},
  "sk":         {"S": "v1#P#20260615140231250#B#20260101000000000"}
}

C · 99 from March onward, known since June

Relational row

-- row C
-- no column
BALANCE_ID  42
QUANTITY    99.0
FROM_Z      2026-03-01 00:00:00.000
THRU_Z      9999-12-01 23:59:00.000
IN_Z        2026-06-15 14:02:31.250
OUT_Z       9999-12-01 23:59:00.000
-- built from BALANCE_ID
-- built from IN_Z, FROM_Z

DynamoDB item

{
  "_rd_v":      {"N": "1"},
  "BALANCE_ID": {"N": "42"},
  "QUANTITY":   {"B": "QFjAAAAAAAA="},
  "FROM_Z":     {"S": "20260301000000000"},
  "THRU_Z":     {"S": "99991201235900000"},
  "IN_Z":       {"S": "20260615140231250"},
  "OUT_Z":      {"S": "99991201235900000"},
  "pk":         {"S": "v1#BALANCE#42"},
  "sk":         {"S": "v1#P#20260615140231250#B#20260301000000000"}
}

THRU_Z and OUT_Z are ordinary attributes, not part of the key. So when June's correction closed row A, the write went to A's existing item: a Put conditioned on that item still holding exactly what Reladomo last read. B and C are new items, written only if attribute_not_exists(pk).

Note that QUANTITY is not stored as a number. A double is stored as its eight IEEE-754 bits, which DynamoDB JSON shows as base64: QCQAAAAAAAA= is 0x4024000000000000, exactly 10.0. The reason is further down.


How the keys are built

Partition key · pk

v1key grammar version#BALANCEsimple class name, upper-cased#42balanceId, the only primary key

Sort key · sk (row C)

v1same version#P#20260615140231250IN_Z, processing-from#B#20260301000000000FROM_Z, business-from

One timestamp (IN_Z)

2026year06month15day14hour02minute31second250millis, from getNanos()
Row C's keys. The partition key names the object with its upper-cased simple class name — not the table name, though for Balance the two coincide — then adds each primary-key value in XML order. The sort key is processing-from, then business-from. No separator appears inside a component: a # in a key value is refused (RELADYNAMO-CFG-008).

Every timestamp is 17 digits: the UTC calendar fields yyyyMMddHHmmssSSS, zero-padded, with milliseconds taken from getNanos(). It is not the familiar ISO-8601 form, and not epoch millis. Because the width is fixed, byte order is time order. Because the digits are calendar fields rather than a signed epoch offset, that stays true before 1970. Infinity has no special token: 99991201235900000 is an ordinary timestamp that sorts after every real date, and it decodes back to the configured sentinel by value.

Timestamp columns in the item use the same form, so the FROM_Z attribute holds exactly the string that ends the sort key. A payload Timestamp with sub-millisecond nanos is refused, not truncated.

CaseKey
Composite primary key, XML orderv1#POSITION#42#7
boolean / BigDecimal / byte[] componentT · 100 (from 1E+2) · 0a1bff00
A # inside a key value · float or double keyrefused
Business-only / audit-only sort keyv1#B#<FROM> · v1#P#<IN>
Non-dated sort keyv1#ND
Size limits, checked before the writepk ≤ 2048 bytes · sk ≤ 1024 bytes

Why the sort key carries both clocks

Look at where the three rows begin. A and B start on the same business date. B and C were recorded at the same instant. Neither clock alone can tell them apart.

business time: FROM_Z to THRU_Z processing time: IN_Z to OUT_Z Jan 1Mar 1∞ Jan 5Jun 15∞ quantity 10 what we believed Jan 5 – Jun 15 quantity 10 Jan 1 – Mar 1 quantity 99 Mar 1 onward A and B: same FROM_Z B and C: same IN_Z A B C
Each item is addressed by one corner of its rectangle, where processing-from meets business-from. Corners can share a column or a row, but no two rectangles share a corner. Only the pair is unique.
RowBusiness-from onlyProcessing-from onlyBoth: what is written
Av1#B#20260101000000000v1#P#20260105093000000v1#P#20260105093000000#B#20260101000000000
Bv1#B#20260101000000000 = Av1#P#20260615140231250v1#P#20260615140231250#B#20260101000000000
Cv1#B#20260301000000000v1#P#20260615140231250 = Bv1#P#20260615140231250#B#20260301000000000

Objects with only one clock need only one component, so business-only and audit-only objects get v1#B#… and v1#P#…. A bitemporal row needs both. Putting processing time first means a Query on pk = v1#BALANCE#42 returns A, B, C in exactly this order: by when they became known, then by business date. The cost is that a predicate on business date alone becomes a filter, as explained in Planning a bitemporal query.


A table with no dates

A non-dated object has no rectangles and one row per key, but it still gets a sort key. Here is the Car object from the car-classifier demo, as its seed inserts the 1985 MR2:

Relational row · CAR

-- row
-- no column
CAR_ID       1
CAR_YEAR     1985
MAKE         Toyota
MODEL        MR2
WHEEL_COUNT  4
COLOR_CODE   RED
BODY_STYLE   COUPE
ENGINE_CODE  4CYL
-- built from CAR_ID
-- constant: no dates

DynamoDB item

{
  "_rd_v":       {"N": "1"},
  "CAR_ID":      {"N": "1"},
  "CAR_YEAR":    {"N": "1985"},
  "MAKE":        {"S": "Toyota"},
  "MODEL":       {"S": "MR2"},
  "WHEEL_COUNT": {"N": "4"},
  "COLOR_CODE":  {"S": "RED"},
  "BODY_STYLE":  {"S": "COUPE"},
  "ENGINE_CODE": {"S": "4CYL"},
  "pk":          {"S": "v1#CAR#1"},
  "sk":          {"S": "v1#ND"}
}

v1#ND is a constant. It gives every Reladynamo table the same key schema, pk plus sk, so table creation, the planner and the executor never have to branch on whether an object is dated. A lookup by carId addresses (v1#CAR#1, v1#ND).


Column types on the wire

Each attribute's DynamoDB type follows from the javaType in the XML. The examples below are the literal values the codec tests expect:

javaTypeStored asExample
int long short byteN{"N": "-128"}
booleanBOOL{"BOOL": false}
String, charS (a lone surrogate char: N){"S": ""} · {"S": "A"}
TimestampS, 17 digits UTC{"S": "20260615140231250"}
Date · TimeS, UTC{"S": "2026-09-13"} · {"S": "14:30:00.123"}
BigDecimalS, plain string{"S": "1.10"}
double · floatB, raw IEEE-754 bits{"B": "QCQAAAAAAAA="}
byte[]B{"B": "AAH/"}
any null valueNULL{"NULL": true}

BigDecimal is a string because DynamoDB's N type trims trailing zeros. 1.10 would come back as 1.1, which DynamoDB considers the same number but Java does not: the scale drops from 2 to 1, equals fails, and money read back no longer matches what H2 returns. toPlainString() keeps the scale. The value must still fit N: more than 38 significant digits, a magnitude outside 1E-130 to 9.99…E+125, or a negative scale is refused rather than written.

A double is bits because N cannot hold NaN, either infinity or −0.0, and its exponent stops at 125 while a double reaches 308. Eight big-endian bytes round-trip every value. The consequence: a condition on a double, like one on a BigDecimal, is evaluated in memory after the read, not by DynamoDB.

Null is written, never implied. A null column becomes {"NULL": true}, never 0 or false, and an empty string stays {"S": ""}. QUANTITY above has no nullable flag, so the parser treats it as nullable, and a null quantity would be stored exactly that way. A null or empty primary-key value is refused. On read, a missing attribute decodes as null if the column is nullable, so a later schema can add columns. The only attributes left off when null are GSI keys, which is how an item drops out of an index.


What else is on the item

Exactly three attributes are not columns: pk, sk and _rd_v, the codec version ({"N": "1"} today). A reader refuses an item with no _rd_v, or with a version it does not know, rather than guessing at its layout. If you configure a GSI, its key attributes are added too; these examples have none. Nothing else is added: no type tag and no write timestamp.

Attribute names come from the XML columnName, or the Java attribute name if a column has none. A mapping that puts a column on pk, sk, _rd_v or a GSI key name is refused when it is built. DynamoDB's reserved words need no special handling: every expression refers to attributes through placeholders (#a0, #a1, …, #rd_v), whether or not the word is reserved today. The whole item, keys included, is sized before it is sent, and anything over 400 KB is refused before it reaches DynamoDB.

These examples assume the writing JVM runs in UTC. The adapter stores the Timestamp Reladomo hands it as UTC calendar fields, and Reladomo's DefaultInfinityTimestamp is 9999-12-01 23:59:00 in the JVM's default timezone. From a JVM in America/Denver, the same infinity is written as 99991202065900000 and midnight on 1 March as 20260301070000000. Writers in different timezones store different bytes for the same data.