@evolu/common 6.0.1-preview.12 → 6.0.1-preview.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,7 @@ import {
45
45
  } from "./Query.js";
46
46
  import {
47
47
  CreateQuery,
48
+ DefaultColumns,
48
49
  EvoluSchema,
49
50
  evoluSchemaToDbSchema,
50
51
  insertable,
@@ -253,34 +254,154 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
253
254
  readonly getSyncState: () => SyncState;
254
255
 
255
256
  /**
256
- * Inserts a row.
257
+ * Inserts a row into the database and returns a {@link Result} with the new
258
+ * {@link Id}.
259
+ *
260
+ * The first argument is the table name, and the second is an object
261
+ * containing the row data. An optional third argument provides mutation
262
+ * options including an `onComplete` callback and `onlyValidate` flag.
263
+ *
264
+ * Returns a Result type - use `.ok` to check if the insertion succeeded, and
265
+ * `.value.id` to access the generated ID on success, or `.error` to handle
266
+ * validation errors.
267
+ *
268
+ * Evolu does not use SQL for mutations to ensure data can be safely and
269
+ * predictably merged without conflicts. Explicit mutations also allow Evolu
270
+ * to automatically add and update {@link DefaultColumns}.
257
271
  *
258
272
  * ### Example
259
273
  *
260
274
  * ```ts
261
- * // TODO:
275
+ * const result = evolu.insert("todo", {
276
+ * title: "Learn Evolu",
277
+ * isCompleted: false,
278
+ * });
279
+ *
280
+ * if (result.ok) {
281
+ * console.log("Todo created with ID:", result.value.id);
282
+ * } else {
283
+ * console.error("Validation error:", result.error);
284
+ * }
285
+ *
286
+ * // With onComplete callback
287
+ * evolu.insert(
288
+ * "todo",
289
+ * { title: "Another todo" },
290
+ * {
291
+ * onComplete: () => {
292
+ * console.log("Insert completed");
293
+ * },
294
+ * },
295
+ * );
262
296
  * ```
263
297
  */
264
298
  insert: Mutation<S, "insert">;
265
299
 
266
300
  /**
267
- * Updates a row.
301
+ * Updates a row in the database and returns a {@link Result} with the existing
302
+ * {@link Id}.
303
+ *
304
+ * The first argument is the table name, and the second is an object
305
+ * containing the row data including the required `id` field. An optional
306
+ * third argument provides mutation options including an `onComplete` callback
307
+ * and `onlyValidate` flag.
308
+ *
309
+ * Returns a Result type - use `.ok` to check if the update succeeded, and
310
+ * `.value.id` to access the ID on success, or `.error` to handle validation
311
+ * errors.
312
+ *
313
+ * Evolu does not use SQL for mutations to ensure data can be safely and
314
+ * predictably merged without conflicts. Explicit mutations also allow Evolu
315
+ * to automatically add and update {@link DefaultColumns}.
268
316
  *
269
317
  * ### Example
270
318
  *
271
319
  * ```ts
272
- * // TODO:
320
+ * const result = evolu.update("todo", {
321
+ * id: todoId,
322
+ * title: "Updated title",
323
+ * isCompleted: true,
324
+ * });
325
+ *
326
+ * if (result.ok) {
327
+ * console.log("Todo updated with ID:", result.value.id);
328
+ * } else {
329
+ * console.error("Validation error:", result.error);
330
+ * }
331
+ *
332
+ * // To delete a row, set isDeleted to true
333
+ * evolu.update("todo", { id: todoId, isDeleted: true });
334
+ *
335
+ * // With onComplete callback
336
+ * evolu.update(
337
+ * "todo",
338
+ * { id: todoId, title: "New title" },
339
+ * {
340
+ * onComplete: () => {
341
+ * console.log("Update completed");
342
+ * },
343
+ * },
344
+ * );
273
345
  * ```
274
346
  */
275
347
  update: Mutation<S, "update">;
276
348
 
277
349
  /**
278
- * Upserts a row.
350
+ * Upserts a row in the database and returns a {@link Result} with the existing
351
+ * {@link Id}.
352
+ *
353
+ * The first argument is the table name, and the second is an object
354
+ * containing the row data including the required `id` field. An optional
355
+ * third argument provides mutation options including an `onComplete` callback
356
+ * and `onlyValidate` flag.
357
+ *
358
+ * This function allows you to use custom IDs and optionally set `createdAt`,
359
+ * which is useful for external systems, data migrations, or when the same row
360
+ * may already be created on a different device.
361
+ *
362
+ * Returns a Result type - use `.ok` to check if the upsert succeeded, and
363
+ * `.value.id` to access the ID on success, or `.error` to handle validation
364
+ * errors.
365
+ *
366
+ * Evolu does not use SQL for mutations to ensure data can be safely and
367
+ * predictably merged without conflicts. Explicit mutations also allow Evolu
368
+ * to automatically add and update {@link DefaultColumns}.
279
369
  *
280
370
  * ### Example
281
371
  *
282
372
  * ```ts
283
- * // TODO:
373
+ * // Use deterministic ID for stable upserts across devices
374
+ * const stableId = createIdFromString("my-todo-1");
375
+ *
376
+ * const result = evolu.upsert("todo", {
377
+ * id: stableId,
378
+ * title: "Learn Evolu",
379
+ * isCompleted: false,
380
+ * });
381
+ *
382
+ * if (result.ok) {
383
+ * console.log("Todo upserted with ID:", result.value.id);
384
+ * } else {
385
+ * console.error("Validation error:", result.error);
386
+ * }
387
+ *
388
+ * // Data migration with custom createdAt
389
+ * evolu.upsert("todo", {
390
+ * id: externalId,
391
+ * title: "Migrated todo",
392
+ * createdAt: new Date("2023-01-01"), // Preserve original timestamp
393
+ * });
394
+ *
395
+ * // With onComplete callback
396
+ * evolu.upsert(
397
+ * "todo",
398
+ * { id: stableId, title: "Updated title" },
399
+ * {
400
+ * onComplete: () => {
401
+ * console.log("Upsert completed");
402
+ * },
403
+ * },
404
+ * );
284
405
  * ```
285
406
  */
286
407
  upsert: Mutation<S, "upsert">;
@@ -594,7 +715,11 @@ const createEvoluInstance =
594
715
  const values = getMutationType(table, "insert").fromUnknown(props);
595
716
 
596
717
  if (values.ok) {
597
- const dbChange = { table, id, values: values.value };
718
+ const valuesWithCreatedAt = {
719
+ ...values.value,
720
+ createdAt: new Date(deps.time.now()).toISOString(),
721
+ };
722
+ const dbChange = { table, id, values: valuesWithCreatedAt };
598
723
  assertValidDbChange(dbChange);
599
724
  initialDataDbChanges.push(dbChange);
600
725
  return ok({ id });
@@ -646,10 +771,23 @@ const createEvoluInstance =
646
771
  // We insert `undefined` to detect such a situation.
647
772
  mutateMicrotaskQueue.push([undefined, undefined]);
648
773
  } else {
649
- // Remove `id` from values.
650
- const { id: _id, ...values } = result.value;
651
- const dbChange = { table, id, values };
774
+ const values = { ...result.value };
775
+
776
+ delete values.id;
777
+ if (kind === "insert" || kind === "upsert") {
778
+ // Only set createdAt if not provided by user
779
+ if (!("createdAt" in values)) {
780
+ values.createdAt = new Date(deps.time.now()).toISOString();
781
+ }
782
+ }
783
+
784
+ const dbChange = {
785
+ table,
786
+ id,
787
+ values,
788
+ };
652
789
  assertValidDbChange(dbChange);
790
+
653
791
  mutateMicrotaskQueue.push([dbChange, options?.onComplete]);
654
792
  }
655
793
 
@@ -188,8 +188,10 @@ import {
188
188
  } from "./Owner.js";
189
189
  import {
190
190
  BinaryTimestamp,
191
+ binaryTimestampLength,
191
192
  binaryTimestampToTimestamp,
192
193
  Counter,
194
+ eqTimestamp,
193
195
  Millis,
194
196
  NodeId,
195
197
  Timestamp,
@@ -399,7 +401,8 @@ export type ProtocolError =
399
401
  | ProtocolInvalidDataError
400
402
  | ProtocolWriteKeyError
401
403
  | ProtocolWriteError
402
- | ProtocolSyncError;
404
+ | ProtocolSyncError
405
+ | ProtocolTimestampMismatchError;
403
406
 
404
407
  /** Base interface for all protocol errors. */
405
408
  export interface ProtocolErrorBase {
@@ -445,6 +448,17 @@ export interface ProtocolSyncError extends ProtocolErrorBase {
445
448
  readonly type: "ProtocolSyncError";
446
449
  }
447
450
 
451
+ /**
452
+ * Error when embedded timestamp doesn't match expected timestamp in
453
+ * EncryptedDbChange. Indicates potential tampering or corruption of CRDT
454
+ * messages.
455
+ */
456
+ export interface ProtocolTimestampMismatchError {
457
+ readonly type: "ProtocolTimestampMismatchError";
458
+ readonly expected: Timestamp;
459
+ readonly embedded: Timestamp;
460
+ }
461
+
448
462
  /**
449
463
  * Creates a {@link ProtocolMessage} from CRDT messages.
450
464
  *
@@ -469,7 +483,7 @@ export const createProtocolMessageFromCrdtMessages =
469
483
 
470
484
  for (const message of messages) {
471
485
  const change = encodeAndEncryptDbChange(deps)(
472
- message.change,
486
+ message,
473
487
  owner.encryptionKey,
474
488
  );
475
489
  const encryptedCrdtMessage = { timestamp: message.timestamp, change };
@@ -1484,7 +1498,7 @@ const decodeTimestamps = (
1484
1498
  for (let i = 0; i < length; i++) {
1485
1499
  const deltaMillis = decodeNonNegativeInt(buffer);
1486
1500
  const millis = Millis.from(previousMillis + deltaMillis);
1487
- if (!millis.ok) throw new Error(millis.error.type);
1501
+ if (!millis.ok) throw new ProtocolDecodeError(millis.error.type);
1488
1502
  millises.push(millis.value);
1489
1503
  previousMillis = millis.value;
1490
1504
  }
@@ -1493,7 +1507,7 @@ const decodeTimestamps = (
1493
1507
  let counterIndex = 0;
1494
1508
  while (counterIndex < length) {
1495
1509
  const counter = Counter.from(decodeNonNegativeInt(buffer));
1496
- if (!counter.ok) throw new Error(counter.error.type);
1510
+ if (!counter.ok) throw new ProtocolDecodeError(counter.error.type);
1497
1511
  const runLength = decodeNonNegativeInt(buffer);
1498
1512
  for (let i = 0; i < runLength; i++) {
1499
1513
  counters.push(counter.value);
@@ -1659,12 +1673,24 @@ export const binaryTimestampToFingerprint = (
1659
1673
  /**
1660
1674
  * Encodes and encrypts a {@link DbChange} using the provided owner's encryption
1661
1675
  * key. Returns an encrypted binary representation as {@link EncryptedDbChange}.
1676
+ *
1677
+ * The format includes the protocol version for backward compatibility and the
1678
+ * timestamp for tamper-proof verification that the timestamp matches the change
1679
+ * data.
1662
1680
  */
1663
1681
  export const encodeAndEncryptDbChange =
1664
1682
  (deps: SymmetricCryptoDep) =>
1665
- (change: DbChange, key: EncryptionKey): EncryptedDbChange => {
1683
+ (message: CrdtMessage, key: EncryptionKey): EncryptedDbChange => {
1684
+ const change = message.change;
1666
1685
  const buffer = createBuffer();
1667
1686
 
1687
+ // Encode protocol version first for backward compatibility
1688
+ encodeNonNegativeInt(buffer, protocolVersion);
1689
+
1690
+ // Encode the timestamp (after version) for tamper verification
1691
+ const binaryTimestamp = timestampToBinaryTimestamp(message.timestamp);
1692
+ buffer.extend(binaryTimestamp);
1693
+
1668
1694
  encodeBase64Url256(buffer, change.table);
1669
1695
 
1670
1696
  buffer.extend(idToBinaryId(change.id));
@@ -1700,50 +1726,76 @@ export const encodeAndEncryptDbChange =
1700
1726
  };
1701
1727
 
1702
1728
  /**
1703
- * Decrypts and decodes an {@link EncryptedDbChange} using the provided owner's
1704
- * encryption key.
1729
+ * Decrypts and decodes an {@link EncryptedCrdtMessage} using the provided
1730
+ * owner's encryption key. Verifies that the embedded timestamp matches the
1731
+ * expected timestamp to ensure message integrity.
1705
1732
  */
1706
1733
  export const decryptAndDecodeDbChange =
1707
1734
  (deps: SymmetricCryptoDep) =>
1708
1735
  (
1709
- change: EncryptedDbChange,
1736
+ message: EncryptedCrdtMessage,
1710
1737
  key: EncryptionKey,
1711
- ): Result<DbChange, SymmetricCryptoDecryptError | ProtocolInvalidDataError> =>
1712
- tryDecodeProtocolData<DbChange, SymmetricCryptoDecryptError>(
1713
- change,
1714
- (buffer) => {
1715
- const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1716
-
1717
- const ciphertextLength = decodeLength(buffer);
1718
- const ciphertext = buffer.shiftN(ciphertextLength);
1719
-
1720
- const plaintextBytes = deps.symmetricCrypto.decrypt(
1721
- ciphertext,
1722
- key,
1723
- nonce,
1724
- );
1725
- if (!plaintextBytes.ok) return plaintextBytes;
1738
+ ): Result<
1739
+ DbChange,
1740
+ | SymmetricCryptoDecryptError
1741
+ | ProtocolInvalidDataError
1742
+ | ProtocolTimestampMismatchError
1743
+ > =>
1744
+ tryDecodeProtocolData<
1745
+ DbChange,
1746
+ SymmetricCryptoDecryptError | ProtocolTimestampMismatchError
1747
+ >(message.change, (buffer) => {
1748
+ const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1749
+
1750
+ const ciphertextLength = decodeLength(buffer);
1751
+ const ciphertext = buffer.shiftN(ciphertextLength);
1752
+
1753
+ const plaintextBytes = deps.symmetricCrypto.decrypt(
1754
+ ciphertext,
1755
+ key,
1756
+ nonce,
1757
+ );
1758
+ if (!plaintextBytes.ok) return plaintextBytes;
1726
1759
 
1727
- buffer.reset();
1728
- buffer.extend(plaintextBytes.value);
1760
+ buffer.reset();
1761
+ buffer.extend(plaintextBytes.value);
1729
1762
 
1730
- const table = decodeBase64Url256WithLength(buffer);
1731
- const id = decodeId(buffer);
1763
+ // Decode version (for future compatibility, no validation needed for now)
1764
+ decodeNonNegativeInt(buffer);
1732
1765
 
1733
- const length = decodeLength(buffer);
1734
- const values = Object.create(null) as Record<string, SqliteValue>;
1766
+ // Decode and verify the embedded timestamp
1767
+ const embeddedBinaryTimestamp = buffer.shiftN(
1768
+ binaryTimestampLength,
1769
+ ) as BinaryTimestamp;
1770
+ const embeddedTimestamp = binaryTimestampToTimestamp(
1771
+ embeddedBinaryTimestamp,
1772
+ );
1735
1773
 
1736
- for (let i = 0; i < length; i++) {
1737
- const column = decodeBase64Url256WithLength(buffer);
1738
- const value = decodeSqliteValue(buffer);
1739
- values[column] = value;
1740
- }
1774
+ // Verify timestamp integrity
1775
+ if (!eqTimestamp(embeddedTimestamp, message.timestamp)) {
1776
+ return err<ProtocolTimestampMismatchError>({
1777
+ type: "ProtocolTimestampMismatchError",
1778
+ expected: message.timestamp,
1779
+ embedded: embeddedTimestamp,
1780
+ });
1781
+ }
1741
1782
 
1742
- const dbChange = { table, id, values };
1783
+ const table = decodeBase64Url256WithLength(buffer);
1784
+ const id = decodeId(buffer);
1743
1785
 
1744
- return ok(dbChange);
1745
- },
1746
- );
1786
+ const length = decodeLength(buffer);
1787
+ const values = Object.create(null) as Record<string, SqliteValue>;
1788
+
1789
+ for (let i = 0; i < length; i++) {
1790
+ const column = decodeBase64Url256WithLength(buffer);
1791
+ const value = decodeSqliteValue(buffer);
1792
+ values[column] = value;
1793
+ }
1794
+
1795
+ const dbChange = { table, id, values };
1796
+
1797
+ return ok(dbChange);
1798
+ });
1747
1799
 
1748
1800
  /**
1749
1801
  * Encodes a non-negative integer into a variable-length integer format. It's
@@ -9,6 +9,7 @@ import {
9
9
  brand,
10
10
  BrandType,
11
11
  createTypeErrorFormatter,
12
+ DateIso,
12
13
  DateIsoString,
13
14
  IdType,
14
15
  InferErrors,
@@ -30,7 +31,11 @@ import { Simplify } from "../Types.js";
30
31
  import { DbSchema } from "./Db.js";
31
32
  import { createIndexes, DbIndexesBuilder } from "./Kysely.js";
32
33
  import { AppOwner, ShardOwner, SharedOwner } from "./Owner.js";
33
- import { BinaryId, maxProtocolMessageRangesSize } from "./Protocol.js";
34
+ import {
35
+ BinaryId,
36
+ maxProtocolMessageRangesSize,
37
+ CrdtMessage,
38
+ } from "./Protocol.js";
34
39
  import { Query, Row } from "./Query.js";
35
40
  import { BinaryTimestamp } from "./Timestamp.js";
36
41
 
@@ -198,6 +203,16 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
198
203
  options?: SqliteQueryOptions,
199
204
  ) => Query<Simplify<R>>;
200
205
 
206
+ /**
207
+ * Default columns automatically added to all tables.
208
+ *
209
+ * - `createdAt`: Set by Evolu when `insert` is called, or can be custom with
210
+ * `upsert`.
211
+ * - `updatedAt`: Always set by Evolu, derived from {@link CrdtMessage} timestamp.
212
+ * If you defer sync to avoid leaking time activity, use a custom column to
213
+ * preserve real update time.
214
+ * - `isDeleted`: Soft delete flag.
215
+ */
201
216
  export const DefaultColumns = object({
202
217
  createdAt: DateIsoString,
203
218
  updatedAt: DateIsoString,
@@ -346,25 +361,41 @@ export type Updateable<Props extends Record<string, AnyType>> = InferInput<
346
361
  >;
347
362
 
348
363
  /**
349
- * Type Factory to create upsertable Type. It makes nullable Types optional and
350
- * ensures the {@link maxMutationSize}.
364
+ * Type Factory to create upsertable Type. It makes nullable Types optional,
365
+ * includes optional default columns (createdAt, isDeleted), and ensures the
366
+ * {@link maxMutationSize}.
351
367
  *
352
368
  * ### Example
353
369
  *
354
370
  * ```ts
355
371
  * const UpsertableTodo = upsertable(Schema.todo);
356
372
  * type UpsertableTodo = typeof UpsertableTodo.Type;
357
- * const todo = UpsertableTodo.from({ id, title });
373
+ * const todo = UpsertableTodo.from({
374
+ * id,
375
+ * title,
376
+ * createdAt: "2023-01-01T00:00:00.000Z",
377
+ * });
358
378
  * if (!todo.ok) return; // handle errors
359
379
  * ```
360
380
  */
361
381
  export const upsertable = <Props extends Record<string, AnyType>>(
362
382
  props: Props,
363
- ): ValidMutationSize<UpsertableProps<Props>> =>
364
- validMutationSize(nullableToOptional(props));
383
+ ): ValidMutationSize<UpsertableProps<Props>> => {
384
+ const propsWithDefaults = {
385
+ ...props,
386
+ createdAt: optional(DateIso),
387
+ isDeleted: optional(SqliteBoolean),
388
+ };
389
+ return validMutationSize(nullableToOptional(propsWithDefaults));
390
+ };
365
391
 
366
392
  export type UpsertableProps<Props extends Record<string, AnyType>> =
367
- NullableToOptionalProps<Props>;
393
+ NullableToOptionalProps<
394
+ Props & {
395
+ createdAt: OptionalType<typeof DateIso>;
396
+ isDeleted: OptionalType<typeof SqliteBoolean>;
397
+ }
398
+ >;
368
399
 
369
400
  export type Upsertable<Props extends Record<string, AnyType>> = InferInput<
370
401
  ObjectType<UpsertableProps<Props>>
@@ -1,4 +1,5 @@
1
1
  import { assert } from "../Assert.js";
2
+ import { createEqObject, eqNumber, eqString } from "../Eq.js";
2
3
  import { NanoIdLibDep } from "../NanoId.js";
3
4
  import { increment } from "../Number.js";
4
5
  import { Order, orderUint8Array } from "../Order.js";
@@ -124,6 +125,13 @@ export const Timestamp = object({
124
125
  });
125
126
  export type Timestamp = typeof Timestamp.Type;
126
127
 
128
+ /** Equality function for comparing {@link Timestamp}. */
129
+ export const eqTimestamp = createEqObject<Timestamp>({
130
+ millis: eqNumber,
131
+ counter: eqNumber,
132
+ nodeId: eqString,
133
+ });
134
+
127
135
  export const createTimestamp = ({
128
136
  millis = minMillis,
129
137
  counter = minCounter,
package/src/Type.ts CHANGED
@@ -1994,7 +1994,11 @@ export const TrimString = trim(String);
1994
1994
  *
1995
1995
  * ### Example
1996
1996
  *
1997
- * TODO:
1997
+ * ```ts
1998
+ * DateIso.from(new Date("2023-12-25T10:30:00.000Z")); // ok("2023-12-25T10:30:00.000Z")
1999
+ * DateIso.to("2023-12-25T10:30:00.000Z"); // Date object
2000
+ * DateIso.from(new Date("invalid")); // err({ type: "DateIsoString", value: "Invalid Date" })
2001
+ * ```
1998
2002
  *
1999
2003
  * @category String
2000
2004
  */