@evolu/common 6.0.1-preview.33 → 6.0.1-preview.35

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/dist/src/Array.d.ts +3 -3
  3. package/dist/src/Array.js +3 -3
  4. package/dist/src/Crypto.d.ts +6 -8
  5. package/dist/src/Crypto.d.ts.map +1 -1
  6. package/dist/src/Crypto.js +9 -9
  7. package/dist/src/Evolu/Db.d.ts.map +1 -1
  8. package/dist/src/Evolu/Db.js +4 -4
  9. package/dist/src/Evolu/Evolu.d.ts +9 -4
  10. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  11. package/dist/src/Evolu/Evolu.js +14 -14
  12. package/dist/src/Evolu/Protocol.d.ts +27 -2
  13. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  14. package/dist/src/Evolu/Protocol.js +71 -31
  15. package/dist/src/Evolu/PublicKysely.d.ts.map +1 -1
  16. package/dist/src/Evolu/PublicKysely.js +0 -1
  17. package/dist/src/Evolu/Query.d.ts +1 -1
  18. package/dist/src/Evolu/Query.d.ts.map +1 -1
  19. package/dist/src/Evolu/Query.js +1 -1
  20. package/dist/src/Evolu/Schema.d.ts +28 -22
  21. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  22. package/dist/src/Evolu/Schema.js +37 -32
  23. package/dist/src/Evolu/Storage.d.ts +11 -2
  24. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  25. package/dist/src/Evolu/Storage.js +21 -3
  26. package/dist/src/Evolu/Sync.d.ts +4 -3
  27. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  28. package/dist/src/Evolu/Sync.js +68 -40
  29. package/dist/src/Evolu/Timestamp.d.ts +10 -14
  30. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  31. package/dist/src/Evolu/Timestamp.js +3 -16
  32. package/dist/src/Object.d.ts +10 -4
  33. package/dist/src/Object.d.ts.map +1 -1
  34. package/dist/src/Object.js +9 -3
  35. package/dist/src/Sqlite.d.ts +29 -3
  36. package/dist/src/Sqlite.d.ts.map +1 -1
  37. package/dist/src/Sqlite.js +29 -3
  38. package/dist/src/Type.d.ts +94 -47
  39. package/dist/src/Type.d.ts.map +1 -1
  40. package/dist/src/Type.js +110 -65
  41. package/package.json +2 -2
  42. package/src/Array.ts +3 -3
  43. package/src/Crypto.ts +11 -9
  44. package/src/Evolu/Db.ts +7 -7
  45. package/src/Evolu/Evolu.ts +31 -24
  46. package/src/Evolu/Protocol.ts +84 -37
  47. package/src/Evolu/PublicKysely.ts +1 -2
  48. package/src/Evolu/Query.ts +2 -2
  49. package/src/Evolu/Schema.ts +54 -50
  50. package/src/Evolu/Storage.ts +39 -2
  51. package/src/Evolu/Sync.ts +97 -43
  52. package/src/Evolu/Timestamp.ts +5 -25
  53. package/src/Object.ts +13 -5
  54. package/src/Sqlite.ts +33 -3
  55. package/src/Type.ts +124 -80
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  AnyType,
16
16
  array,
17
+ createIdFromString,
17
18
  DateIso,
18
19
  IdBytes,
19
20
  InferErrors,
@@ -40,7 +41,7 @@ import { Simplify } from "../Types.js";
40
41
  import { AppOwner, OwnerId } from "./Owner.js";
41
42
  import { Query, Row } from "./Query.js";
42
43
  import { CrdtMessage, DbChange } from "./Storage.js";
43
- import { TimestampBytes } from "./Timestamp.js";
44
+ import { Timestamp, TimestampBytes } from "./Timestamp.js";
44
45
 
45
46
  /**
46
47
  * Defines the schema of an Evolu database.
@@ -92,17 +93,17 @@ export type EvoluSchema = ReadonlyRecord<
92
93
  *
93
94
  * 1. All tables must have an 'id' column
94
95
  * 2. The 'id' column must be a branded ID type (created with id() function)
95
- * 3. Tables cannot use default column names (createdAt, updatedAt, isDeleted)
96
+ * 3. Tables cannot use system column names (createdAt, updatedAt, isDeleted)
96
97
  * 4. All column types must be compatible with SQLite (extend SqliteValue)
97
98
  */
98
99
  export type ValidateSchema<S extends EvoluSchema> =
99
100
  ValidateSchemaHasId<S> extends never
100
101
  ? ValidateIdColumnType<S> extends never
101
- ? ValidateNoDefaultColumns<S> extends never
102
+ ? ValidateNoSystemColumns<S> extends never
102
103
  ? ValidateColumnTypes<S> extends never
103
104
  ? S
104
105
  : ValidateColumnTypes<S>
105
- : ValidateNoDefaultColumns<S>
106
+ : ValidateNoSystemColumns<S>
106
107
  : ValidateIdColumnType<S>
107
108
  : ValidateSchemaHasId<S>;
108
109
 
@@ -126,13 +127,17 @@ export type ValidateIdColumnType<S extends EvoluSchema> =
126
127
  : never
127
128
  : never;
128
129
 
129
- export type ValidateNoDefaultColumns<S extends EvoluSchema> =
130
+ export type ValidateNoSystemColumns<S extends EvoluSchema> =
130
131
  keyof S extends infer TableName
131
132
  ? TableName extends keyof S
132
133
  ? keyof S[TableName] extends infer ColumnName
133
134
  ? ColumnName extends keyof S[TableName]
134
- ? ColumnName extends "createdAt" | "updatedAt" | "isDeleted"
135
- ? SchemaValidationError<`Table "${TableName & string}" uses default column name "${ColumnName & string}". Default columns (createdAt, updatedAt, isDeleted) are added automatically.`>
135
+ ? ColumnName extends
136
+ | "createdAt"
137
+ | "updatedAt"
138
+ | "isDeleted"
139
+ | "ownerId"
140
+ ? SchemaValidationError<`Table "${TableName & string}" uses system column name "${ColumnName & string}". System columns (createdAt, updatedAt, isDeleted, ownerId) are added automatically.`>
136
141
  : never
137
142
  : never
138
143
  : never
@@ -189,13 +194,10 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
189
194
  Kysely.Kysely<
190
195
  {
191
196
  [Table in keyof S]: {
192
- readonly [Column in keyof S[Table]]: Column extends
193
- | "id"
194
- | "createdAt"
195
- | "updatedAt"
197
+ readonly [Column in keyof S[Table]]: Column extends "id"
196
198
  ? InferType<S[Table][Column]>
197
199
  : InferType<S[Table][Column]> | null;
198
- } & DefaultColumns;
200
+ } & SystemColumns;
199
201
  } & {
200
202
  readonly evolu_history: {
201
203
  readonly timestamp: TimestampBytes;
@@ -213,21 +215,23 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
213
215
  ) => Query<Simplify<R>>;
214
216
 
215
217
  /**
216
- * Default columns automatically added to all tables.
218
+ * System columns that are implicitly defined by Evolu.
217
219
  *
218
- * - `createdAt`: Set by Evolu when `insert` is called, or can be custom with
219
- * `upsert`.
220
- * - `updatedAt`: Always set by Evolu, derived from {@link CrdtMessage} timestamp.
221
- * If you defer sync to avoid leaking time activity, use a custom column to
222
- * preserve real update time.
223
- * - `isDeleted`: Soft delete flag.
220
+ * - `createdAt`: Set by Evolu on row creation, derived from {@link Timestamp}.
221
+ * - `updatedAt`: Set by Evolu on every row change, derived from {@link Timestamp}.
222
+ * - `isDeleted`: Soft delete flag created by Evolu and used by the developer to
223
+ * mark rows as deleted.
224
+ * - `ownerId`: Represents ownership and logically partitions the database.
224
225
  */
225
- export const DefaultColumns = object({
226
+ export const SystemColumns = object({
226
227
  createdAt: DateIso,
227
228
  updatedAt: DateIso,
228
229
  isDeleted: nullOr(SqliteBoolean),
230
+ ownerId: OwnerId,
229
231
  });
230
- export type DefaultColumns = typeof DefaultColumns.Type;
232
+ export type SystemColumns = typeof SystemColumns.Type;
233
+
234
+ export const systemColumns = Object.keys(SystemColumns.props);
231
235
 
232
236
  export type MutationKind = "insert" | "update" | "upsert";
233
237
 
@@ -311,7 +315,8 @@ export interface MutationChange extends DbChange {
311
315
 
312
316
  /**
313
317
  * Type Factory to create insertable {@link Type}. It makes nullable Types
314
- * optional, omits Id, and ensures the {@link maxMutationSize}.
318
+ * optional (so they are not required), omits Id, and ensures the
319
+ * {@link maxMutationSize}.
315
320
  *
316
321
  * ### Example
317
322
  *
@@ -341,7 +346,7 @@ export type Insertable<Props extends Record<string, AnyType>> = InferInput<
341
346
 
342
347
  /**
343
348
  * Type Factory to create updateable {@link Type}. It makes everything except for
344
- * the `id` column partial (i.e. optional) and ensures the
349
+ * the `id` column optional (so they are not required) and ensures the
345
350
  * {@link maxMutationSize}.
346
351
  *
347
352
  * ### Example
@@ -377,9 +382,15 @@ export type Updateable<Props extends Record<string, AnyType>> = InferInput<
377
382
  >;
378
383
 
379
384
  /**
380
- * Type Factory to create upsertable Type. It makes nullable Types optional,
381
- * includes optional default columns (createdAt, isDeleted), and ensures the
382
- * {@link maxMutationSize}.
385
+ * Type Factory to create an upsertable Type. It makes nullable Types optional
386
+ * (so they are not required) and ensures the {@link maxMutationSize}.
387
+ *
388
+ * Upsert is like insert, except it requires an ID. It's useful for inserting
389
+ * rows with external ID via {@link createIdFromString}.
390
+ *
391
+ * Note that it's not possible to upsert a row with `createdAt` nor `updatedAt`,
392
+ * because they are derived from {@link CrdtMessage} timestamp. For external
393
+ * createdAt, use a different column.
383
394
  *
384
395
  * ### Example
385
396
  *
@@ -389,7 +400,6 @@ export type Updateable<Props extends Record<string, AnyType>> = InferInput<
389
400
  * const todo = UpsertableTodo.from({
390
401
  * id,
391
402
  * title,
392
- * createdAt: "2023-01-01T00:00:00.000Z",
393
403
  * });
394
404
  * if (!todo.ok) return; // handle errors
395
405
  * ```
@@ -399,7 +409,6 @@ export const upsertable = <Props extends Record<string, AnyType>>(
399
409
  ): ValidMutationSize<UpsertableProps<Props>> => {
400
410
  const propsWithDefaults = {
401
411
  ...props,
402
- createdAt: optional(DateIso),
403
412
  isDeleted: optional(SqliteBoolean),
404
413
  };
405
414
  return validMutationSize(nullableToOptional(propsWithDefaults));
@@ -408,7 +417,6 @@ export const upsertable = <Props extends Record<string, AnyType>>(
408
417
  export type UpsertableProps<Props extends Record<string, AnyType>> =
409
418
  NullableToOptionalProps<
410
419
  Props & {
411
- createdAt: OptionalType<typeof DateIso>;
412
420
  isDeleted: OptionalType<typeof SqliteBoolean>;
413
421
  }
414
422
  >;
@@ -534,10 +542,7 @@ export const ensureDbSchema =
534
542
  (t) => t.name === newTable.name,
535
543
  );
536
544
  if (!currentTable) {
537
- queries.push({
538
- sql: createTableWithDefaultColumns(newTable.name, newTable.columns),
539
- parameters: [],
540
- });
545
+ queries.push(createAppTable(newTable));
541
546
  } else {
542
547
  newTable.columns
543
548
  .filter((newColumn) => !currentTable.columns.includes(newColumn))
@@ -583,24 +588,23 @@ export const ensureDbSchema =
583
588
  return ok();
584
589
  };
585
590
 
586
- const createTableWithDefaultColumns = (
587
- tableName: string,
588
- columns: ReadonlyArray<string>,
589
- ): SafeSql =>
590
- `
591
- create table ${sql.identifier(tableName).sql} (
592
- "id" text primary key,
593
- ${columns
594
- // Add default columns.
595
- .concat(["createdAt", "updatedAt", "isDeleted"])
591
+ const createAppTable = (table: DbTable) => sql`
592
+ create table ${sql.identifier(table.name)} (
593
+ "id" text,
594
+ ${sql.raw(
595
+ `${systemColumns
596
+ .concat(table.columns)
596
597
  .filter((c) => c !== "id")
597
- // "A column with affinity BLOB does not prefer one storage class over another
598
- // and no attempt is made to coerce data from one storage class into another."
599
- // https://www.sqlite.org/datatype3.html
600
- .map((name) => `${sql.identifier(name).sql} blob`)
601
- .join(", ")}
602
- );
603
- ` as SafeSql;
598
+ // With strict tables and any type, data is preserved exactly as received
599
+ // without any type affinity coercion. This allows storing any data type
600
+ // while maintaining strict null enforcement for primary key columns.
601
+ .map((name) => `${sql.identifier(name).sql} any`)
602
+ .join(", ")}`,
603
+ )},
604
+ primary key ("ownerId", "id")
605
+ )
606
+ without rowid, strict;
607
+ `;
604
608
 
605
609
  // https://kysely.dev/docs/recipes/splitting-query-building-and-execution
606
610
  export const kysely = new Kysely.Kysely({
@@ -9,17 +9,21 @@ import { Brand } from "../Brand.js";
9
9
  import { concatBytes } from "../Buffer.js";
10
10
  import { decrement } from "../Number.js";
11
11
  import { RandomDep } from "../Random.js";
12
- import { ok, Result } from "../Result.js";
12
+ import { err, ok, Result } from "../Result.js";
13
13
  import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
14
14
  import { MaybeAsync } from "../Task.js";
15
15
  import {
16
+ Boolean,
17
+ brand,
16
18
  Id,
17
19
  Int64String,
18
20
  NonNegativeInt,
21
+ nullOr,
19
22
  object,
20
23
  PositiveInt,
21
24
  record,
22
25
  String,
26
+ TypeError,
23
27
  } from "../Type.js";
24
28
  import {
25
29
  BaseOwnerError,
@@ -28,6 +32,7 @@ import {
28
32
  OwnerIdBytes,
29
33
  OwnerWriteKey,
30
34
  } from "./Owner.js";
35
+ import { systemColumns } from "./Schema.js";
31
36
  import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
32
37
 
33
38
  export interface StorageConfig {
@@ -248,6 +253,36 @@ export interface CrdtMessage {
248
253
  readonly change: DbChange;
249
254
  }
250
255
 
256
+ export const DbChangeValues = record(String, SqliteValue);
257
+ export type DbChangeValues = typeof DbChangeValues.Type;
258
+
259
+ // "createdAt" and "updatedAt" are derived from Timestamp.
260
+ // "id" and "isDeleted" are encoded separately to save bytes
261
+ // "ownerId" is part of the protocol message
262
+ const forbiddenSystemColumns = systemColumns.concat("id");
263
+
264
+ export const ValidDbChangeValues = brand(
265
+ "ValidDbChangeValues",
266
+ DbChangeValues,
267
+ (value) => {
268
+ const invalidColumns = forbiddenSystemColumns.filter((key) => key in value);
269
+ if (invalidColumns.length > 0)
270
+ return err<ValidDbChangeValuesError>({
271
+ type: "ValidDbChangeValues",
272
+ value,
273
+ invalidColumns,
274
+ });
275
+
276
+ return ok(value);
277
+ },
278
+ );
279
+ export type ValidDbChangeValues = typeof ValidDbChangeValues.Type;
280
+
281
+ export interface ValidDbChangeValuesError
282
+ extends TypeError<"ValidDbChangeValues"> {
283
+ readonly invalidColumns: ReadonlyArray<string>;
284
+ }
285
+
251
286
  /**
252
287
  * A DbChange is a change to a table row. Together with a unique
253
288
  * {@link Timestamp}, it forms a {@link CrdtMessage}.
@@ -255,7 +290,9 @@ export interface CrdtMessage {
255
290
  export const DbChange = object({
256
291
  table: String,
257
292
  id: Id,
258
- values: record(String, SqliteValue),
293
+ values: ValidDbChangeValues,
294
+ isInsert: Boolean,
295
+ isDelete: nullOr(Boolean),
259
296
  });
260
297
  export type DbChange = typeof DbChange.Type;
261
298
 
package/src/Evolu/Sync.ts CHANGED
@@ -19,12 +19,27 @@ import { objectToEntries } from "../Object.js";
19
19
  import { RandomDep } from "../Random.js";
20
20
  import { createResources } from "../Resources.js";
21
21
  import { err, ok, Result } from "../Result.js";
22
- import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
22
+ import {
23
+ booleanToSqliteBoolean,
24
+ sql,
25
+ SqliteBoolean,
26
+ sqliteBooleanToBoolean,
27
+ SqliteDep,
28
+ SqliteError,
29
+ sqliteFalse,
30
+ SqliteValue,
31
+ } from "../Sqlite.js";
23
32
  import { AbortError, createMutex } from "../Task.js";
24
33
  import { TimeDep } from "../Time.js";
25
- import { IdBytes, idBytesToId, idToIdBytes, PositiveInt } from "../Type.js";
34
+ import {
35
+ DateIso,
36
+ IdBytes,
37
+ idBytesToId,
38
+ idToIdBytes,
39
+ PositiveInt,
40
+ } from "../Type.js";
26
41
  import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
27
- import type { PostMessageDep } from "./Db.js";
42
+ import type { AppOwnerDep, PostMessageDep } from "./Db.js";
28
43
  import {
29
44
  AppOwner,
30
45
  OwnerEncryptionKey,
@@ -74,8 +89,8 @@ import {
74
89
  TimestampCounterOverflowError,
75
90
  TimestampDriftError,
76
91
  TimestampTimeOutOfRangeError,
92
+ timestampToDateIso,
77
93
  timestampToTimestampBytes,
78
- timestampToTimestampString,
79
94
  } from "./Timestamp.js";
80
95
 
81
96
  export interface Sync extends Disposable {
@@ -341,7 +356,10 @@ export const createSync =
341
356
  clockTimestamp = nextTimestamp.value;
342
357
 
343
358
  const { ownerId = config.appOwner.id, ...dbChange } = change;
344
- const message = { timestamp: clockTimestamp, change: dbChange };
359
+ const message: CrdtMessage = {
360
+ timestamp: clockTimestamp,
361
+ change: dbChange,
362
+ };
345
363
 
346
364
  const messages = ownerMessages.get(ownerId);
347
365
  if (messages) messages.push(message);
@@ -397,7 +415,6 @@ export interface ClockDep {
397
415
  readonly clock: Clock;
398
416
  }
399
417
 
400
- // HLC
401
418
  export interface Clock {
402
419
  readonly get: () => Timestamp;
403
420
  readonly save: (timestamp: Timestamp) => Result<void, SqliteError>;
@@ -413,9 +430,9 @@ export const createClock =
413
430
  save: (timestamp) => {
414
431
  currentTimestamp = timestamp;
415
432
 
416
- const timestampString = timestampToTimestampString(timestamp);
417
433
  const result = deps.sqlite.exec(sql.prepared`
418
- update evolu_config set "clock" = ${timestampString};
434
+ update evolu_config
435
+ set "clock" = ${timestampToTimestampBytes(timestamp)};
419
436
  `);
420
437
  if (!result.ok) return result;
421
438
 
@@ -587,20 +604,37 @@ const createClientStorage =
587
604
 
588
605
  const { table, id } = rows[0];
589
606
  const values: Record<string, SqliteValue> = {};
607
+ let isInsert = false;
608
+ let isDelete: boolean | null = null;
590
609
 
591
610
  for (const r of rows) {
592
611
  assert(r.table === table, "All rows must have the same table");
593
612
  assert(eqArrayNumber(r.id, id), "All rows must have the same Id");
594
- values[r.column] = r.value;
613
+ switch (r.column) {
614
+ case "createdAt":
615
+ isInsert = true;
616
+ break;
617
+ case "isDeleted":
618
+ assert(
619
+ SqliteBoolean.is(r.value),
620
+ "isDeleted column must contain a valid SqliteBoolean (0 or 1)",
621
+ );
622
+ isDelete = sqliteBooleanToBoolean(r.value);
623
+ break;
624
+ default:
625
+ values[r.column] = r.value;
626
+ }
595
627
  }
596
628
 
597
629
  const message: CrdtMessage = {
598
630
  timestamp: timestampBytesToTimestamp(timestamp),
599
- change: {
631
+ change: DbChange.orThrow({
600
632
  table: rows[0].table,
601
633
  id: idBytesToId(rows[0].id),
602
634
  values,
603
- },
635
+ isInsert,
636
+ isDelete,
637
+ }),
604
638
  };
605
639
 
606
640
  return encodeAndEncryptDbChange(deps)(message, owner.encryptionKey);
@@ -618,35 +652,32 @@ const createTransportKey = (transportConfig: OwnerTransport): TransportKey => {
618
652
  };
619
653
 
620
654
  export const applyLocalOnlyChange =
621
- (deps: SqliteDep & TimeDep) =>
655
+ (deps: SqliteDep & TimeDep & AppOwnerDep) =>
622
656
  (change: MutationChange): Result<void, SqliteError> => {
623
- const dbChange: DbChange = {
624
- table: change.table,
625
- id: change.id,
626
- values: change.values,
627
- };
628
-
629
- const isDeletion =
630
- "isDeleted" in dbChange.values && dbChange.values.isDeleted === 1;
631
-
632
- if (isDeletion) {
657
+ if (change.isDelete) {
633
658
  const result = deps.sqlite.exec(sql`
634
- delete from ${sql.identifier(dbChange.table)}
635
- where id = ${dbChange.id};
659
+ delete from ${sql.identifier(change.table)}
660
+ where id = ${change.id};
636
661
  `);
637
662
  if (!result.ok) return result;
638
663
  } else {
639
- const date = new Date(deps.time.now()).toISOString();
664
+ const now = deps.time.nowIso();
665
+ const ownerId = deps.appOwner.id;
666
+
667
+ let entries = objectToEntries(change.values);
668
+ if (change.isDelete !== null) {
669
+ entries = [...entries, ["isDeleted", sqliteFalse]];
670
+ }
640
671
 
641
- for (const [column, value] of objectToEntries(dbChange.values)) {
672
+ for (const [column, value] of entries) {
642
673
  const result = deps.sqlite.exec(sql.prepared`
643
- insert into ${sql.identifier(dbChange.table)}
644
- ("id", ${sql.identifier(column)}, createdAt, updatedAt)
645
- values (${dbChange.id}, ${value}, ${date}, ${date})
646
- on conflict ("id") do update
674
+ insert into ${sql.identifier(change.table)}
675
+ ("ownerId", "id", ${sql.identifier(column)}, createdAt, updatedAt)
676
+ values (${ownerId}, ${change.id}, ${value}, ${now}, ${now})
677
+ on conflict ("ownerId", "id") do update
647
678
  set
648
679
  ${sql.identifier(column)} = ${value},
649
- updatedAt = ${date};
680
+ updatedAt = ${now};
650
681
  `);
651
682
  if (!result.ok) return result;
652
683
  }
@@ -672,7 +703,8 @@ const applyMessages =
672
703
  let { firstTimestamp, lastTimestamp } = usageResult.value;
673
704
 
674
705
  for (const message of messages) {
675
- const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message);
706
+ const date = timestampToDateIso(message.timestamp);
707
+ const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message, date);
676
708
  if (!result1.ok) return result1;
677
709
 
678
710
  const timestamp = timestampToTimestampBytes(message.timestamp);
@@ -688,6 +720,7 @@ const applyMessages =
688
720
  ownerIdBytes,
689
721
  message,
690
722
  strategy,
723
+ date,
691
724
  );
692
725
  if (!result2.ok) return result2;
693
726
  }
@@ -710,32 +743,36 @@ const applyMessages =
710
743
 
711
744
  const applyMessageToAppTable =
712
745
  (deps: SqliteDep) =>
713
- (ownerId: OwnerIdBytes, message: CrdtMessage): Result<void, SqliteError> => {
714
- const timestamp = timestampToTimestampBytes(message.timestamp);
715
- const updatedAt = new Date(message.timestamp.millis).toISOString();
746
+ (
747
+ ownerIdBytes: OwnerIdBytes,
748
+ message: CrdtMessage,
749
+ date: DateIso,
750
+ ): Result<void, SqliteError> => {
751
+ const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
752
+ const columns = dbChangeToColumns(message.change, date);
716
753
 
717
- for (const [column, value] of objectToEntries(message.change.values)) {
754
+ for (const [column, value] of columns) {
718
755
  const result = deps.sqlite.exec(sql.prepared`
719
756
  with
720
757
  existingTimestamp as (
721
758
  select 1
722
759
  from evolu_history
723
760
  where
724
- "ownerId" = ${ownerId}
761
+ "ownerId" = ${ownerIdBytes}
725
762
  and "table" = ${message.change.table}
726
763
  and "id" = ${idToIdBytes(message.change.id)}
727
764
  and "column" = ${column}
728
- and "timestamp" >= ${timestamp}
765
+ and "timestamp" >= ${timestampToTimestampBytes(message.timestamp)}
729
766
  limit 1
730
767
  )
731
768
  insert into ${sql.identifier(message.change.table)}
732
- ("id", ${sql.identifier(column)}, updatedAt)
733
- select ${message.change.id}, ${value}, ${updatedAt}
769
+ ("ownerId", "id", ${sql.identifier(column)}, updatedAt)
770
+ select ${ownerId}, ${message.change.id}, ${value}, ${date}
734
771
  where not exists (select 1 from existingTimestamp)
735
- on conflict ("id") do update
772
+ on conflict ("ownerId", "id") do update
736
773
  set
737
774
  ${sql.identifier(column)} = ${value},
738
- updatedAt = ${updatedAt}
775
+ updatedAt = ${date}
739
776
  where not exists (select 1 from existingTimestamp);
740
777
  `);
741
778
 
@@ -751,6 +788,7 @@ export const applyMessageToTimestampAndHistoryTables =
751
788
  ownerId: OwnerIdBytes,
752
789
  message: CrdtMessage,
753
790
  strategy: StorageInsertTimestampStrategy,
791
+ date: DateIso,
754
792
  ): Result<void, SqliteError> => {
755
793
  const timestamp = timestampToTimestampBytes(message.timestamp);
756
794
  const id = idToIdBytes(message.change.id);
@@ -758,7 +796,9 @@ export const applyMessageToTimestampAndHistoryTables =
758
796
  const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
759
797
  if (!result.ok) return result;
760
798
 
761
- for (const [column, value] of Object.entries(message.change.values)) {
799
+ const columns = dbChangeToColumns(message.change, date);
800
+
801
+ for (const [column, value] of columns) {
762
802
  const result = deps.sqlite.exec(sql.prepared`
763
803
  insert into evolu_history
764
804
  ("ownerId", "table", "id", "column", "value", "timestamp")
@@ -779,6 +819,20 @@ export const applyMessageToTimestampAndHistoryTables =
779
819
  return ok();
780
820
  };
781
821
 
822
+ const dbChangeToColumns = (
823
+ change: DbChange,
824
+ date: DateIso,
825
+ ): Array<[string, SqliteValue | DateIso]> => {
826
+ const entries = [...objectToEntries(change.values)];
827
+ if (change.isInsert) {
828
+ entries.push(["createdAt", date]);
829
+ }
830
+ if (change.isDelete !== null) {
831
+ entries.push(["isDeleted", booleanToSqliteBoolean(change.isDelete)]);
832
+ }
833
+ return entries;
834
+ };
835
+
782
836
  /**
783
837
  * TODO: Rework for the new owners API.
784
838
  *
@@ -1,5 +1,3 @@
1
- import { assert } from "../Assert.js";
2
- import { Brand } from "../Brand.js";
3
1
  import { bytesToHex } from "../Buffer.js";
4
2
  import { RandomBytesDep } from "../Crypto.js";
5
3
  import { createEqObject, eqNumber, eqString } from "../Eq.js";
@@ -9,6 +7,7 @@ import { err, ok, Result } from "../Result.js";
9
7
  import { TimeDep } from "../Time.js";
10
8
  import {
11
9
  brand,
10
+ DateIso,
12
11
  InferType,
13
12
  lessThanOrEqualTo,
14
13
  NonNegativeInt,
@@ -192,29 +191,6 @@ export const createInitialTimestamp = (deps: RandomBytesDep): Timestamp => {
192
191
  return createTimestamp({ nodeId });
193
192
  };
194
193
 
195
- /** Sortable string representation of {@link Timestamp}. */
196
- export type TimestampString = string & Brand<"TimestampString">;
197
-
198
- export const timestampToTimestampString = (t: Timestamp): TimestampString =>
199
- [
200
- new Date(t.millis).toISOString(),
201
- t.counter.toString(16).toUpperCase().padStart(4, "0"),
202
- t.nodeId,
203
- ].join("-") as TimestampString;
204
-
205
- export const timestampStringToTimestamp = (
206
- timestampString: TimestampString,
207
- ): Timestamp => {
208
- const array = timestampString.split("-");
209
- const timestamp = {
210
- millis: Date.parse(array.slice(0, 3).join("-")).valueOf(),
211
- counter: parseInt(array[3], 16),
212
- nodeId: array[4],
213
- };
214
- assert(Timestamp.is(timestamp), "timestampString is malformed");
215
- return timestamp;
216
- };
217
-
218
194
  const getNextMillis =
219
195
  (deps: TimeDep & TimestampConfigDep) =>
220
196
  (
@@ -361,3 +337,7 @@ export const timestampBytesToTimestamp = (
361
337
  };
362
338
 
363
339
  export const orderTimestampBytes: Order<TimestampBytes> = orderUint8Array;
340
+
341
+ export const timestampToDateIso = (timestamp: Timestamp): DateIso =>
342
+ // `as DateIso` is safe because the timestamp is always valid
343
+ new Date(timestamp.millis).toISOString() as DateIso;
package/src/Object.ts CHANGED
@@ -25,14 +25,22 @@ export type ReadonlyRecord<K extends keyof any, V> = Readonly<Record<K, V>>;
25
25
  type StringKeyOf<T> = Extract<keyof T, string>;
26
26
 
27
27
  /**
28
- * Converts a record to entries, preserving branded string key types (e.g.,
29
- * `type Id = 'id' & string`) via `StringKeyOf<T>`, unlike `Object.entries`
30
- * which widens keys to `string`.
28
+ * Like `Object.entries` but preserves branded keys.
29
+ *
30
+ * ### Example
31
+ *
32
+ * ```ts
33
+ * type UserId = string & { readonly __brand: "UserId" };
34
+ * const users: Record<UserId, string> = {};
35
+ * const entries = objectToEntries(users); // [UserId, string][]
36
+ * ```
31
37
  */
32
38
  export const objectToEntries = <T extends Record<string, any>>(
33
39
  record: T,
34
- ): Array<[StringKeyOf<T>, T[StringKeyOf<T>]]> =>
35
- Object.entries(record) as Array<[StringKeyOf<T>, T[StringKeyOf<T>]]>;
40
+ ): ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]> =>
41
+ Object.entries(record) as Array<
42
+ [StringKeyOf<T>, T[StringKeyOf<T>]]
43
+ > as ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]>;
36
44
 
37
45
  /**
38
46
  * Maps a `ReadonlyRecord<K, V>` to a new `ReadonlyRecord<K, U>`, preserving