Skip to content

Commit 8f0c0d3

Browse files
committed
Improve createdAt column handling
1 parent 770ca5b commit 8f0c0d3

20 files changed

Lines changed: 857 additions & 667 deletions

File tree

.changeset/clear-mails-float.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@evolu/common": patch
3+
---
4+
5+
Refined system (formerly "default") createdAt column handling
6+
7+
### Summary
8+
9+
- `createdAt` is now derived exclusively from the CRDT `Timestamp`. It is injected automatically only on first insert. You can no longer provide `createdAt` in `upsert` mutation – doing so was an anti‑pattern and is now validated against.
10+
- Introduced `isInsert` flag to `DbChange` to distinguish initial row creation from subsequent updates; this drives automatic `createdAt` population.
11+
- Added `ValidDbChangeValues` type to reject system columns (`createdAt`, `updatedAt`, `id`) while allowing `isDeleted`.
12+
- Clock storage changed from sortable string (`TimestampString`) to compact binary (`blob`) representation for space efficiency and fewer conversions.
13+
- Removed `timestampToTimestampString` / `timestampStringToTimestamp`; added `timestampToDateIso` for converting CRDT timestamps to ISO dates.
14+
- Schema validation wording updated: "default column" -> "system column" for clarity.
15+
- Internal protocol encoding updated (tests reflect new binary clock and flag ordering); snapshots adjusted accordingly.
16+
17+
### Notes
18+
19+
- This change reduces payload size (e.g. from 113 to 97).

apps/relay/.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
db.sqlite
1212
package-lock.json
13-
evolu-relay-0.db
1413

1514
# Persistent data directory contents (but keep the directory)
1615
data/*

apps/web/src/lib/llms.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const excludePaths = [
6868
"comparison",
6969
"conventions",
7070
"dependency-injection",
71-
"evolu-relay",
71+
"relay",
7272
"faq",
7373
// Add other paths to exclude as needed
7474
];

examples/vue-vite-pwa/src/App.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
sqliteTrue,
88
createEvolu,
99
createFormatTypeError,
10-
getOrThrow,
1110
id,
1211
kysely,
1312
maxLength,

packages/common/src/Evolu/Db.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ import {
6363
} from "./Sync.js";
6464
import {
6565
Timestamp,
66+
TimestampBytes,
67+
timestampBytesToTimestamp,
6668
TimestampConfig,
6769
TimestampError,
68-
TimestampString,
69-
timestampStringToTimestamp,
70-
timestampToTimestampString,
70+
timestampToTimestampBytes,
7171
} from "./Timestamp.js";
7272

7373
export interface DbConfig extends ConsoleConfig, TimestampConfig {
@@ -387,7 +387,7 @@ const createDbWorkerDeps =
387387
// }
388388

389389
const configResult = sqlite.exec<{
390-
clock: TimestampString;
390+
clock: TimestampBytes;
391391
appOwnerId: OwnerId;
392392
appOwnerEncryptionKey: OwnerEncryptionKey;
393393
appOwnerWriteKey: OwnerWriteKey;
@@ -415,7 +415,7 @@ const createDbWorkerDeps =
415415
};
416416

417417
clock = createClock({ ...platformDeps, sqlite })(
418-
timestampStringToTimestamp(config.clock),
418+
timestampBytesToTimestamp(config.clock),
419419
);
420420
} else {
421421
appOwner =
@@ -507,7 +507,7 @@ const initializeDb =
507507

508508
sql`
509509
create table evolu_config (
510-
"clock" text not null,
510+
"clock" blob not null,
511511
"appOwnerId" text not null,
512512
"appOwnerEncryptionKey" blob not null,
513513
"appOwnerWriteKey" blob not null,
@@ -527,7 +527,7 @@ const initializeDb =
527527
)
528528
values
529529
(
530-
${timestampToTimestampString(initialClock)},
530+
${timestampToTimestampBytes(initialClock)},
531531
${initialAppOwner.id},
532532
${initialAppOwner.encryptionKey},
533533
${initialAppOwner.writeKey},

packages/common/src/Evolu/Evolu.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
isNonEmptyArray,
55
isNonEmptyReadonlyArray,
66
} from "../Array.js";
7-
import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
7+
import { assertNonEmptyReadonlyArray } from "../Assert.js";
88
import { createCallbacks } from "../Callbacks.js";
99
import { ConsoleDep } from "../Console.js";
1010
import { RandomBytesDep, SymmetricCryptoDecryptError } from "../Crypto.js";
@@ -48,7 +48,6 @@ import {
4848
} from "./Query.js";
4949
import {
5050
CreateQuery,
51-
DefaultColumns,
5251
EvoluSchema,
5352
evoluSchemaToDbSchema,
5453
IndexesConfig,
@@ -59,6 +58,7 @@ import {
5958
MutationKind,
6059
MutationMapping,
6160
MutationOptions,
61+
SystemColumns,
6262
updateable,
6363
upsertable,
6464
ValidateSchema,
@@ -239,7 +239,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
239239
*
240240
* Evolu does not use SQL for mutations to ensure data can be safely and
241241
* predictably merged without conflicts. Explicit mutations also allow Evolu
242-
* to automatically add and update {@link DefaultColumns}.
242+
* to automatically add and update {@link SystemColumns}.
243243
*
244244
* ### Example
245245
*
@@ -284,7 +284,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
284284
*
285285
* Evolu does not use SQL for mutations to ensure data can be safely and
286286
* predictably merged without conflicts. Explicit mutations also allow Evolu
287-
* to automatically add and update {@link DefaultColumns}.
287+
* to automatically add and update {@link SystemColumns}.
288288
*
289289
* ### Example
290290
*
@@ -337,7 +337,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
337337
*
338338
* Evolu does not use SQL for mutations to ensure data can be safely and
339339
* predictably merged without conflicts. Explicit mutations also allow Evolu
340-
* to automatically add and update {@link DefaultColumns}.
340+
* to automatically add and update {@link SystemColumns}.
341341
*
342342
* ### Example
343343
*
@@ -408,7 +408,12 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
408408
*/
409409
readonly reloadApp: () => void;
410410

411-
/** Export SQLite database file as Uint8Array. */
411+
/**
412+
* Export SQLite database file as Uint8Array.
413+
*
414+
* In the future, it will be possible to import a database and export/import
415+
* history for 1:1 migrations across owners.
416+
*/
412417
readonly exportDatabase: () => Promise<Uint8Array<ArrayBuffer>>;
413418

414419
/**
@@ -716,21 +721,17 @@ const createEvoluInstance =
716721
const values = { ...result.value };
717722
delete values.id;
718723

719-
if (kind === "insert" || kind === "upsert") {
720-
// Only set createdAt if not provided by user
721-
if (!("createdAt" in values)) {
722-
values.createdAt = new Date(deps.time.now()).toISOString();
723-
}
724-
}
725-
726-
const dbChange = { table, id, values };
727-
assert(
728-
DbChange.is(dbChange),
729-
`Failed to create DbChange for table "${dbChange.table}"`,
730-
);
724+
const dbChange = DbChange.orThrow({
725+
table,
726+
id,
727+
values,
728+
isInsert: kind === "insert" || kind === "upsert",
729+
});
731730

732-
const mutationChange = { ...dbChange, ownerId: options?.ownerId };
733-
mutateMicrotaskQueue.push([mutationChange, options?.onComplete]);
731+
mutateMicrotaskQueue.push([
732+
{ ...dbChange, ownerId: options?.ownerId },
733+
options?.onComplete,
734+
]);
734735
}
735736

736737
if (mutateMicrotaskQueue.length === 1) {

packages/common/src/Evolu/Protocol.ts

Lines changed: 70 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@
171171
* `applyProtocolMessage` function with conditional arguments to reduce code
172172
* duplication.
173173
* - ProtocolQuotaError should return storedBytes and actual quota.
174+
* - Replace try-catch with Result + new Error (to preserve stacktraces). Measure
175+
* Result overhead, it should be super small.
174176
*/
175177

176178
import { Packr } from "msgpackr";
@@ -186,8 +188,8 @@ import {
186188
utf8ToBytes,
187189
} from "../Buffer.js";
188190
import {
191+
createPadmePadding,
189192
EncryptionKey,
190-
padmePaddingLength,
191193
RandomBytesDep,
192194
SymmetricCryptoDecryptError,
193195
SymmetricCryptoDep,
@@ -436,7 +438,7 @@ export interface ProtocolSyncError extends BaseOwnerError {
436438
export interface ProtocolTimestampMismatchError {
437439
readonly type: "ProtocolTimestampMismatchError";
438440
readonly expected: Timestamp;
439-
readonly embedded: Timestamp;
441+
readonly timestamp: Timestamp;
440442
}
441443

442444
/**
@@ -909,8 +911,6 @@ export const applyProtocolMessageAsClient =
909911
| ProtocolQuotaError
910912
>
911913
> => {
912-
// try-catch instead of Result for performance and stacktraces
913-
// DEV: Measure it again, I think we should use Result with new Error.
914914
try {
915915
const input = createBuffer(inputMessage);
916916
const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
@@ -1057,8 +1057,6 @@ export const applyProtocolMessageAsRelay =
10571057
): Promise<
10581058
Result<ApplyProtocolMessageAsRelayResult, ProtocolInvalidDataError>
10591059
> => {
1060-
// try-catch instead of Result for performance and stacktraces
1061-
// DEV: Measure it again, I think we should use Result with new Error.
10621060
try {
10631061
const input = createBuffer(inputMessage);
10641062
const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
@@ -1664,6 +1662,52 @@ export const decodeNumber = (buffer: Buffer): number => {
16641662
return numberResult.value;
16651663
};
16661664

1665+
/**
1666+
* Encodes an array of boolean flags into a single byte.
1667+
*
1668+
* Each element in the array corresponds to a bit (0-7). Array can have 0-8
1669+
* elements.
1670+
*
1671+
* ### Example
1672+
*
1673+
* ```ts
1674+
* encodeFlags(buffer, [true, false, true]); // Encodes bits 0, 1, 2
1675+
* ```
1676+
*/
1677+
export const encodeFlags = (
1678+
buffer: Buffer,
1679+
flags: ReadonlyArray<boolean>,
1680+
): void => {
1681+
let byte = 0;
1682+
for (let i = 0; i < flags.length && i < 8; i++) {
1683+
if (flags[i]) {
1684+
byte |= 1 << i;
1685+
}
1686+
}
1687+
buffer.extend([byte]);
1688+
};
1689+
1690+
/**
1691+
* Decodes a byte into an array of boolean flags.
1692+
*
1693+
* ### Example
1694+
*
1695+
* ```ts
1696+
* const flags = decodeFlags(buffer, 3); // Decode 3 flags
1697+
* ```
1698+
*/
1699+
export const decodeFlags = (
1700+
buffer: Buffer,
1701+
count: PositiveInt,
1702+
): ReadonlyArray<boolean> => {
1703+
const byte = buffer.shift();
1704+
const flags: Array<boolean> = [];
1705+
for (let i = 0; i < count && i < 8; i++) {
1706+
flags.push((byte & (1 << i)) !== 0);
1707+
}
1708+
return flags;
1709+
};
1710+
16671711
/**
16681712
* Encodes and encrypts a {@link DbChange} using the provided owner's encryption
16691713
* key. Returns an encrypted binary representation as {@link EncryptedDbChange}.
@@ -1675,36 +1719,29 @@ export const decodeNumber = (buffer: Buffer): number => {
16751719
export const encodeAndEncryptDbChange =
16761720
(deps: SymmetricCryptoDep) =>
16771721
(message: CrdtMessage, key: EncryptionKey): EncryptedDbChange => {
1678-
const change = message.change;
16791722
const buffer = createBuffer();
16801723

1681-
// Encode protocol version first for backward compatibility
16821724
encodeNonNegativeInt(buffer, protocolVersion);
16831725

1684-
// Encode the timestamp (after version) for tamper verification
1685-
const timestampBytes = timestampToTimestampBytes(message.timestamp);
1686-
buffer.extend(timestampBytes);
1726+
// Encode the timestamp to prevent tampering (e.g., a malicious relay
1727+
// assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1728+
buffer.extend(timestampToTimestampBytes(message.timestamp));
16871729

1688-
encodeString(buffer, change.table);
1730+
encodeFlags(buffer, [message.change.isInsert]);
16891731

1690-
buffer.extend(idToIdBytes(change.id));
1732+
encodeString(buffer, message.change.table);
1733+
buffer.extend(idToIdBytes(message.change.id));
16911734

1692-
const entries = objectToEntries(change.values).map(
1693-
([column, value]): [string, SqliteValue] => {
1694-
return [column, value];
1695-
},
1696-
);
1735+
const entries = objectToEntries(message.change.values);
16971736

16981737
encodeLength(buffer, entries);
1699-
17001738
for (const [column, value] of entries) {
17011739
encodeString(buffer, column);
17021740
encodeSqliteValue(buffer, value);
17031741
}
17041742

1705-
const paddingLength = padmePaddingLength(buffer.getLength());
1706-
// Add zero bytes as PADMÉ padding - these will be ignored during decoding.
1707-
buffer.extend(new Uint8Array(paddingLength));
1743+
// Add PADMÉ padding (ignored during decoding)
1744+
buffer.extend(createPadmePadding(buffer.getLength()));
17081745

17091746
const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(
17101747
buffer.unwrap(),
@@ -1735,13 +1772,11 @@ export const decryptAndDecodeDbChange =
17351772
| ProtocolInvalidDataError
17361773
| ProtocolTimestampMismatchError
17371774
> => {
1738-
// try-catch instead of Result for performance and stacktraces
17391775
try {
17401776
const buffer = createBuffer(message.change);
1741-
const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
17421777

1743-
const ciphertextLength = decodeLength(buffer);
1744-
const ciphertext = buffer.shiftN(ciphertextLength);
1778+
const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1779+
const ciphertext = buffer.shiftN(decodeLength(buffer));
17451780

17461781
const plaintextBytes = deps.symmetricCrypto.decrypt(
17471782
ciphertext,
@@ -1753,24 +1788,24 @@ export const decryptAndDecodeDbChange =
17531788
buffer.reset();
17541789
buffer.extend(plaintextBytes.value);
17551790

1756-
// Decode version (for future compatibility, no validation needed for now)
1791+
// Decode version (for future compatibility, not need yet)
17571792
decodeNonNegativeInt(buffer);
17581793

1759-
// Decode and verify the embedded timestamp
1760-
const embeddedTimestampBytes = buffer.shiftN(timestampBytesLength);
1761-
const embeddedTimestamp = timestampBytesToTimestamp(
1762-
embeddedTimestampBytes as TimestampBytes,
1794+
const timestamp = timestampBytesToTimestamp(
1795+
buffer.shiftN(timestampBytesLength) as TimestampBytes,
17631796
);
17641797

1765-
// Verify timestamp integrity
1766-
if (!eqTimestamp(embeddedTimestamp, message.timestamp)) {
1798+
if (!eqTimestamp(timestamp, message.timestamp)) {
17671799
return err<ProtocolTimestampMismatchError>({
17681800
type: "ProtocolTimestampMismatchError",
17691801
expected: message.timestamp,
1770-
embedded: embeddedTimestamp,
1802+
timestamp,
17711803
});
17721804
}
17731805

1806+
const flags = decodeFlags(buffer, PositiveInt.orThrow(1));
1807+
const isInsert = flags[0];
1808+
17741809
const table = decodeString(buffer);
17751810
const id = decodeId(buffer);
17761811

@@ -1783,7 +1818,7 @@ export const decryptAndDecodeDbChange =
17831818
values[column] = value;
17841819
}
17851820

1786-
const dbChange = { table, id, values };
1821+
const dbChange = DbChange.orThrow({ table, id, values, isInsert });
17871822

17881823
return ok(dbChange);
17891824
} catch (error) {

0 commit comments

Comments
 (0)