@evolu/common 6.0.1-preview.34 → 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.
@@ -170,9 +170,11 @@
170
170
  * initiator/non-initiator terminology instead, and consolidate into a single
171
171
  * `applyProtocolMessage` function with conditional arguments to reduce code
172
172
  * duplication.
173
- * - ProtocolQuotaError should return storedBytes and actual quota.
174
173
  * - Replace try-catch with Result + new Error (to preserve stacktraces). Measure
175
174
  * Result overhead, it should be super small.
175
+ * - Allow clients to broadcast messages that are not persisted by relays. This
176
+ * would enable real-time ephemeral data (like cursor positions, typing
177
+ * indicators) to be forwarded by relays without storage overhead.
176
178
  */
177
179
 
178
180
  import { Packr } from "msgpackr";
@@ -416,7 +418,9 @@ export interface ProtocolWriteError extends BaseOwnerError {
416
418
  * excess local data is affected. Other devices that haven't exceeded quota can
417
419
  * still sync normally.
418
420
  *
419
- * Clients should prompt the user to upgrade their plan.
421
+ * Clients should prompt the user to contact the relay provider or upgrade their
422
+ * plan. Quota monitoring and management is the relay provider's
423
+ * responsibility.
420
424
  */
421
425
  export interface ProtocolQuotaError extends BaseOwnerError {
422
426
  readonly type: "ProtocolQuotaError";
@@ -1727,7 +1731,11 @@ export const encodeAndEncryptDbChange =
1727
1731
  // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1728
1732
  buffer.extend(timestampToTimestampBytes(message.timestamp));
1729
1733
 
1730
- encodeFlags(buffer, [message.change.isInsert]);
1734
+ encodeFlags(buffer, [
1735
+ message.change.isInsert,
1736
+ message.change.isDelete != null,
1737
+ message.change.isDelete ?? false,
1738
+ ]);
1731
1739
 
1732
1740
  encodeString(buffer, message.change.table);
1733
1741
  buffer.extend(idToIdBytes(message.change.id));
@@ -1803,9 +1811,7 @@ export const decryptAndDecodeDbChange =
1803
1811
  });
1804
1812
  }
1805
1813
 
1806
- const flags = decodeFlags(buffer, PositiveInt.orThrow(1));
1807
- const isInsert = flags[0];
1808
-
1814
+ const flags = decodeFlags(buffer, PositiveInt.orThrow(3));
1809
1815
  const table = decodeString(buffer);
1810
1816
  const id = decodeId(buffer);
1811
1817
 
@@ -1818,7 +1824,13 @@ export const decryptAndDecodeDbChange =
1818
1824
  values[column] = value;
1819
1825
  }
1820
1826
 
1821
- const dbChange = DbChange.orThrow({ table, id, values, isInsert });
1827
+ const dbChange = DbChange.orThrow({
1828
+ table,
1829
+ id,
1830
+ values,
1831
+ isInsert: flags[0],
1832
+ isDelete: flags[1] ? flags[2] : null,
1833
+ });
1822
1834
 
1823
1835
  return ok(dbChange);
1824
1836
  } catch (error) {
@@ -209,8 +209,7 @@ export function getJsonObjectArgs(
209
209
  table: string,
210
210
  ): Array<Expression<unknown> | string> {
211
211
  const args: Array<Expression<unknown> | string> = [];
212
- args.push(kyselyJsonIdentifier, kyselyJsonIdentifier);
213
-
212
+
214
213
  for (const { selection: s } of node.selections ?? []) {
215
214
  if (ReferenceNode.is(s) && ColumnNode.is(s.column)) {
216
215
  args.push(
@@ -1,6 +1,4 @@
1
1
  import * as Kysely from "kysely";
2
- import { assert } from "../Assert.js";
3
- import { createEqArrayLike, eqString } from "../Eq.js";
4
2
  import { mapObject, objectToEntries, ReadonlyRecord } from "../Object.js";
5
3
  import { ok, Result } from "../Result.js";
6
4
  import {
@@ -101,11 +99,11 @@ export type EvoluSchema = ReadonlyRecord<
101
99
  export type ValidateSchema<S extends EvoluSchema> =
102
100
  ValidateSchemaHasId<S> extends never
103
101
  ? ValidateIdColumnType<S> extends never
104
- ? ValidateNoDefaultColumns<S> extends never
102
+ ? ValidateNoSystemColumns<S> extends never
105
103
  ? ValidateColumnTypes<S> extends never
106
104
  ? S
107
105
  : ValidateColumnTypes<S>
108
- : ValidateNoDefaultColumns<S>
106
+ : ValidateNoSystemColumns<S>
109
107
  : ValidateIdColumnType<S>
110
108
  : ValidateSchemaHasId<S>;
111
109
 
@@ -129,13 +127,17 @@ export type ValidateIdColumnType<S extends EvoluSchema> =
129
127
  : never
130
128
  : never;
131
129
 
132
- export type ValidateNoDefaultColumns<S extends EvoluSchema> =
130
+ export type ValidateNoSystemColumns<S extends EvoluSchema> =
133
131
  keyof S extends infer TableName
134
132
  ? TableName extends keyof S
135
133
  ? keyof S[TableName] extends infer ColumnName
136
134
  ? ColumnName extends keyof S[TableName]
137
- ? ColumnName extends "createdAt" | "updatedAt" | "isDeleted"
138
- ? SchemaValidationError<`Table "${TableName & string}" uses system column name "${ColumnName & string}". System 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.`>
139
141
  : never
140
142
  : never
141
143
  : never
@@ -192,10 +194,7 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
192
194
  Kysely.Kysely<
193
195
  {
194
196
  [Table in keyof S]: {
195
- readonly [Column in keyof S[Table]]: Column extends
196
- | "id"
197
- | "createdAt"
198
- | "updatedAt"
197
+ readonly [Column in keyof S[Table]]: Column extends "id"
199
198
  ? InferType<S[Table][Column]>
200
199
  : InferType<S[Table][Column]> | null;
201
200
  } & SystemColumns;
@@ -222,24 +221,17 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
222
221
  * - `updatedAt`: Set by Evolu on every row change, derived from {@link Timestamp}.
223
222
  * - `isDeleted`: Soft delete flag created by Evolu and used by the developer to
224
223
  * mark rows as deleted.
224
+ * - `ownerId`: Represents ownership and logically partitions the database.
225
225
  */
226
226
  export const SystemColumns = object({
227
227
  createdAt: DateIso,
228
228
  updatedAt: DateIso,
229
229
  isDeleted: nullOr(SqliteBoolean),
230
- // TODO: ownerId
230
+ ownerId: OwnerId,
231
231
  });
232
232
  export type SystemColumns = typeof SystemColumns.Type;
233
233
 
234
- export const systemColumns = ["createdAt", "updatedAt", "isDeleted"];
235
-
236
- assert(
237
- createEqArrayLike(eqString)(
238
- objectToEntries(SystemColumns.props).map(([key]) => key),
239
- systemColumns,
240
- ),
241
- "SystemColumns keys must match systemColumnsKeys",
242
- );
234
+ export const systemColumns = Object.keys(SystemColumns.props);
243
235
 
244
236
  export type MutationKind = "insert" | "update" | "upsert";
245
237
 
@@ -550,10 +542,7 @@ export const ensureDbSchema =
550
542
  (t) => t.name === newTable.name,
551
543
  );
552
544
  if (!currentTable) {
553
- queries.push({
554
- sql: createAppTableWithDefaultColumns(newTable),
555
- parameters: [],
556
- });
545
+ queries.push(createAppTable(newTable));
557
546
  } else {
558
547
  newTable.columns
559
548
  .filter((newColumn) => !currentTable.columns.includes(newColumn))
@@ -599,21 +588,24 @@ export const ensureDbSchema =
599
588
  return ok();
600
589
  };
601
590
 
602
- const createAppTableWithDefaultColumns = (table: DbTable): SafeSql => {
603
- return `
604
- create table ${sql.identifier(table.name).sql} (
605
- "id" text primary key,
606
- ${table.columns
607
- .concat(systemColumns)
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)
608
597
  .filter((c) => c !== "id")
609
- // "A column with affinity BLOB does not prefer one storage class over another
610
- // and no attempt is made to coerce data from one storage class into another."
611
- // https://www.sqlite.org/datatype3.html
612
- .map((name) => `${sql.identifier(name).sql} blob`)
613
- .join(", ")}
614
- );
615
- ` as SafeSql;
616
- };
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
+ `;
608
+
617
609
  // https://kysely.dev/docs/recipes/splitting-query-building-and-execution
618
610
  export const kysely = new Kysely.Kysely({
619
611
  dialect: {
@@ -1,6 +1,5 @@
1
1
  import { sha256 } from "@noble/hashes/sha2.js";
2
2
  import {
3
- filterArray,
4
3
  firstInArray,
5
4
  isNonEmptyReadonlyArray,
6
5
  NonEmptyReadonlyArray,
@@ -19,6 +18,7 @@ import {
19
18
  Id,
20
19
  Int64String,
21
20
  NonNegativeInt,
21
+ nullOr,
22
22
  object,
23
23
  PositiveInt,
24
24
  record,
@@ -32,8 +32,8 @@ import {
32
32
  OwnerIdBytes,
33
33
  OwnerWriteKey,
34
34
  } from "./Owner.js";
35
- import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
36
35
  import { systemColumns } from "./Schema.js";
36
+ import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
37
37
 
38
38
  export interface StorageConfig {
39
39
  /**
@@ -256,18 +256,16 @@ export interface CrdtMessage {
256
256
  export const DbChangeValues = record(String, SqliteValue);
257
257
  export type DbChangeValues = typeof DbChangeValues.Type;
258
258
 
259
- const systemColumnsWithIdAndWithoutIsDeleted = filterArray(
260
- systemColumns.concat("id"),
261
- (column) => column != "isDeleted",
262
- );
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
263
 
264
264
  export const ValidDbChangeValues = brand(
265
265
  "ValidDbChangeValues",
266
266
  DbChangeValues,
267
267
  (value) => {
268
- const invalidColumns = systemColumnsWithIdAndWithoutIsDeleted.filter(
269
- (key) => key in value,
270
- );
268
+ const invalidColumns = forbiddenSystemColumns.filter((key) => key in value);
271
269
  if (invalidColumns.length > 0)
272
270
  return err<ValidDbChangeValuesError>({
273
271
  type: "ValidDbChangeValues",
@@ -294,6 +292,7 @@ export const DbChange = object({
294
292
  id: Id,
295
293
  values: ValidDbChangeValues,
296
294
  isInsert: Boolean,
295
+ isDelete: nullOr(Boolean),
297
296
  });
298
297
  export type DbChange = typeof DbChange.Type;
299
298
 
package/src/Evolu/Sync.ts CHANGED
@@ -19,7 +19,16 @@ 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
34
  import {
@@ -30,7 +39,7 @@ import {
30
39
  PositiveInt,
31
40
  } from "../Type.js";
32
41
  import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
33
- import type { PostMessageDep } from "./Db.js";
42
+ import type { AppOwnerDep, PostMessageDep } from "./Db.js";
34
43
  import {
35
44
  AppOwner,
36
45
  OwnerEncryptionKey,
@@ -596,14 +605,24 @@ const createClientStorage =
596
605
  const { table, id } = rows[0];
597
606
  const values: Record<string, SqliteValue> = {};
598
607
  let isInsert = false;
608
+ let isDelete: boolean | null = null;
599
609
 
600
610
  for (const r of rows) {
601
611
  assert(r.table === table, "All rows must have the same table");
602
612
  assert(eqArrayNumber(r.id, id), "All rows must have the same Id");
603
- if (r.column === "createdAt") {
604
- isInsert = true;
605
- } else {
606
- 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;
607
626
  }
608
627
  }
609
628
 
@@ -614,6 +633,7 @@ const createClientStorage =
614
633
  id: idBytesToId(rows[0].id),
615
634
  values,
616
635
  isInsert,
636
+ isDelete,
617
637
  }),
618
638
  };
619
639
 
@@ -632,12 +652,9 @@ const createTransportKey = (transportConfig: OwnerTransport): TransportKey => {
632
652
  };
633
653
 
634
654
  export const applyLocalOnlyChange =
635
- (deps: SqliteDep & TimeDep) =>
655
+ (deps: SqliteDep & TimeDep & AppOwnerDep) =>
636
656
  (change: MutationChange): Result<void, SqliteError> => {
637
- const isDeletion =
638
- "isDeleted" in change.values && change.values.isDeleted === 1;
639
-
640
- if (isDeletion) {
657
+ if (change.isDelete) {
641
658
  const result = deps.sqlite.exec(sql`
642
659
  delete from ${sql.identifier(change.table)}
643
660
  where id = ${change.id};
@@ -645,13 +662,19 @@ export const applyLocalOnlyChange =
645
662
  if (!result.ok) return result;
646
663
  } else {
647
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
+ }
648
671
 
649
- for (const [column, value] of objectToEntries(change.values)) {
672
+ for (const [column, value] of entries) {
650
673
  const result = deps.sqlite.exec(sql.prepared`
651
674
  insert into ${sql.identifier(change.table)}
652
- ("id", ${sql.identifier(column)}, createdAt, updatedAt)
653
- values (${change.id}, ${value}, ${now}, ${now})
654
- on conflict ("id") do update
675
+ ("ownerId", "id", ${sql.identifier(column)}, createdAt, updatedAt)
676
+ values (${ownerId}, ${change.id}, ${value}, ${now}, ${now})
677
+ on conflict ("ownerId", "id") do update
655
678
  set
656
679
  ${sql.identifier(column)} = ${value},
657
680
  updatedAt = ${now};
@@ -721,23 +744,21 @@ const applyMessages =
721
744
  const applyMessageToAppTable =
722
745
  (deps: SqliteDep) =>
723
746
  (
724
- ownerId: OwnerIdBytes,
747
+ ownerIdBytes: OwnerIdBytes,
725
748
  message: CrdtMessage,
726
749
  date: DateIso,
727
750
  ): Result<void, SqliteError> => {
728
- let entries = objectToEntries(message.change.values);
729
- if (message.change.isInsert) {
730
- entries = [...entries, ["createdAt", date]];
731
- }
751
+ const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
752
+ const columns = dbChangeToColumns(message.change, date);
732
753
 
733
- for (const [column, value] of entries) {
754
+ for (const [column, value] of columns) {
734
755
  const result = deps.sqlite.exec(sql.prepared`
735
756
  with
736
757
  existingTimestamp as (
737
758
  select 1
738
759
  from evolu_history
739
760
  where
740
- "ownerId" = ${ownerId}
761
+ "ownerId" = ${ownerIdBytes}
741
762
  and "table" = ${message.change.table}
742
763
  and "id" = ${idToIdBytes(message.change.id)}
743
764
  and "column" = ${column}
@@ -745,10 +766,10 @@ const applyMessageToAppTable =
745
766
  limit 1
746
767
  )
747
768
  insert into ${sql.identifier(message.change.table)}
748
- ("id", ${sql.identifier(column)}, updatedAt)
749
- select ${message.change.id}, ${value}, ${date}
769
+ ("ownerId", "id", ${sql.identifier(column)}, updatedAt)
770
+ select ${ownerId}, ${message.change.id}, ${value}, ${date}
750
771
  where not exists (select 1 from existingTimestamp)
751
- on conflict ("id") do update
772
+ on conflict ("ownerId", "id") do update
752
773
  set
753
774
  ${sql.identifier(column)} = ${value},
754
775
  updatedAt = ${date}
@@ -775,12 +796,9 @@ export const applyMessageToTimestampAndHistoryTables =
775
796
  const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
776
797
  if (!result.ok) return result;
777
798
 
778
- let entries = objectToEntries(message.change.values);
779
- if (message.change.isInsert) {
780
- entries = [...entries, ["createdAt", date]];
781
- }
799
+ const columns = dbChangeToColumns(message.change, date);
782
800
 
783
- for (const [column, value] of entries) {
801
+ for (const [column, value] of columns) {
784
802
  const result = deps.sqlite.exec(sql.prepared`
785
803
  insert into evolu_history
786
804
  ("ownerId", "table", "id", "column", "value", "timestamp")
@@ -801,6 +819,20 @@ export const applyMessageToTimestampAndHistoryTables =
801
819
  return ok();
802
820
  };
803
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
+
804
836
  /**
805
837
  * TODO: Rework for the new owners API.
806
838
  *
package/src/Sqlite.ts CHANGED
@@ -511,10 +511,14 @@ const drawSqliteQueryPlan = (rows: Array<SqliteQueryPlanRow>): string =>
511
511
  * SQLite represents boolean values using `0` (false) and `1` (true) instead of
512
512
  * a dedicated boolean type.
513
513
  *
514
- * Use {@link sqliteTrue} and {@link sqliteFalse} constants for better
515
- * readability.
516
- *
517
514
  * See: https://www.sqlite.org/quirks.html#no_separate_boolean_datatype
515
+ *
516
+ * ### Tips
517
+ *
518
+ * - Use {@link sqliteTrue} and {@link sqliteFalse} constants for better
519
+ * readability.
520
+ * - Use {@link booleanToSqliteBoolean} and {@link sqliteBooleanToBoolean} for
521
+ * converting between JavaScript booleans and SQLite boolean values.
518
522
  */
519
523
  export const SqliteBoolean = union(0, 1);
520
524
  export type SqliteBoolean = typeof SqliteBoolean.Type;
@@ -532,3 +536,29 @@ export const sqliteTrue = 1;
532
536
  * See {@link SqliteBoolean}.
533
537
  */
534
538
  export const sqliteFalse = 0;
539
+
540
+ /**
541
+ * Converts a JavaScript boolean to a {@link SqliteBoolean}.
542
+ *
543
+ * ### Example
544
+ *
545
+ * ```ts
546
+ * const isActive = true;
547
+ * const sqlValue = booleanToSqliteBoolean(isActive); // Returns 1
548
+ * ```
549
+ */
550
+ export const booleanToSqliteBoolean = (value: boolean): SqliteBoolean =>
551
+ value ? sqliteTrue : sqliteFalse;
552
+
553
+ /**
554
+ * Converts a {@link SqliteBoolean} to a JavaScript boolean.
555
+ *
556
+ * ### Example
557
+ *
558
+ * ```ts
559
+ * const sqlValue: SqliteBoolean = 1;
560
+ * const bool = sqliteBooleanToBoolean(sqlValue); // Returns true
561
+ * ```
562
+ */
563
+ export const sqliteBooleanToBoolean = (value: SqliteBoolean): boolean =>
564
+ value === sqliteTrue;
package/src/Type.ts CHANGED
@@ -212,6 +212,7 @@ import { isPlainObject } from "./Object.js";
212
212
  import { hasNodeBuffer } from "./Platform.js";
213
213
  import { err, getOrNull, getOrThrow, ok, Result, trySync } from "./Result.js";
214
214
  import { safelyStringifyUnknownValue } from "./String.js";
215
+ import type { TimeDep } from "./Time.js";
215
216
  import type { Literal, Simplify, WidenLiteral } from "./Types.js";
216
217
  import { IntentionalNever } from "./Types.js";
217
218
 
@@ -1589,41 +1590,27 @@ export const formatSimplePasswordError = (
1589
1590
  );
1590
1591
 
1591
1592
  /**
1592
- * Globally unique identifier.
1593
- *
1594
- * **Evolu Id** is 16 random bytes from a cryptographically secure random
1595
- * generator, encoded as 22-character Base64Url string. This provides strong
1596
- * collision resistance for distributed ID generation.
1597
- *
1598
- * ### Design Rationale
1599
- *
1600
- * Why Evolu Id over alternatives:
1601
- *
1602
- * - **NanoID**: No standard binary serialization format, and uses only ~126 bits
1603
- * of entropy (21 characters from 64-symbol alphabet) compared to Evolu Id's
1604
- * 128 bits.
1605
- * - **UUID (v4)**: String format is 36 characters (with hyphens) compared to
1606
- * Evolu Id's 22 characters. While UUIDs can be stored as 16 bytes, their
1607
- * standard string representation is verbose.
1608
- * - **UUID v7**: Includes timestamp in the ID, which leaks information about when
1609
- * data was created. This is a privacy concern for local-first applications
1610
- * where creation time must remain private.
1611
- *
1612
- * Evolu Id provides 128 bits of entropy, compact string representation (22
1613
- * characters), standard and native string serialization (Base64Url), and no
1614
- * privacy leaks.
1615
- *
1616
- * ### Future Consideration
1617
- *
1618
- * For database-heavy workloads where insert performance is critical, a hybrid
1619
- * approach could be considered: `timestamp ^ H(cluster_id, timestamp >> N)`
1620
- * where H is a keyed hash function and N is a configurable parameter. This
1621
- * would maintain spatial locality for database caches (improving insert
1622
- * performance by an order of magnitude) while adding entropy to prevent
1623
- * timestamp leakage and correlation across systems. The parameter N would allow
1624
- * trading off cache locality (larger N = better locality) versus entropy
1625
- * distribution. See https://brooker.co.za/blog/2025/10/22/uuidv7.html for
1626
- * details on this approach.
1593
+ * Evolu Id: 16 bytes encoded as a 22‑character Base64Url string.
1594
+ *
1595
+ * There are three ways to create an Evolu Id:
1596
+ *
1597
+ * - {@link createId} default cryptographically secure random bytes
1598
+ * (privacy‑preserving)
1599
+ * - {@link createIdFromString} – deterministic: first 16 bytes of SHA‑256 of a
1600
+ * string
1601
+ * - {@link createIdAsUuidv7} optional: embeds timestamp bits (UUID v7 layout)
1602
+ *
1603
+ * Privacy: the default random Id does not leak creation time and is safe to
1604
+ * share or log. The UUID v7 variant leaks creation time anywhere the Id is
1605
+ * copied (logs, URLs, exports); only use it when you explicitly want insertion
1606
+ * locality for very large write‑heavy tables and accept timestamp exposure.
1607
+ *
1608
+ * ### Future
1609
+ *
1610
+ * A possible hybrid masked‑time approach (`timestamp ^ H(cluster_id, timestamp
1611
+ *
1612
+ * > > N)`) could provide locality without exposing raw creation time. See
1613
+ * > > https://brooker.co.za/blog/2025/10/22/uuidv7.html
1627
1614
  *
1628
1615
  * @category String
1629
1616
  */
@@ -1641,26 +1628,25 @@ export const formatIdError = createTypeErrorFormatter<IdError>(
1641
1628
  );
1642
1629
 
1643
1630
  /**
1644
- * Creates an {@link Id}.
1631
+ * Creates a random {@link Id}. This is the recommended default.
1632
+ *
1633
+ * Use {@link createIdFromString} for deterministic mapping of external IDs or
1634
+ * {@link createIdAsUuidv7} when you accept timestamp leakage for index
1635
+ * locality.
1645
1636
  *
1646
1637
  * ### Example
1647
1638
  *
1648
1639
  * ```ts
1649
- * // string & Brand<"Id">
1650
1640
  * const id = createId(deps);
1651
- *
1652
- * // string & Brand<"Id"> & Brand<"Todo">
1653
1641
  * const todoId = createId<"Todo">(deps);
1654
1642
  * ```
1655
1643
  */
1656
1644
  export const createId = <B extends string = never>(
1657
1645
  deps: RandomBytesDep,
1658
- ): [B] extends [never] ? Id : Id & Brand<B> =>
1659
- uint8ArrayToBase64Url(deps.randomBytes.create(16)) as unknown as [B] extends [
1660
- never,
1661
- ]
1662
- ? Id
1663
- : Id & Brand<B>;
1646
+ ): [B] extends [never] ? Id : Id & Brand<B> => {
1647
+ const id = uint8ArrayToBase64Url(deps.randomBytes.create(16));
1648
+ return id as unknown as [B] extends [never] ? Id : Id & Brand<B>;
1649
+ };
1664
1650
 
1665
1651
  /**
1666
1652
  * Creates an {@link Id} from a string using SHA-256.
@@ -1704,6 +1690,42 @@ export const createIdFromString = <B extends string = never>(
1704
1690
  return id as [B] extends [never] ? Id : Id & Brand<B>;
1705
1691
  };
1706
1692
 
1693
+ /**
1694
+ * Creates an {@link Id} embedding timestamp bits (UUID v7 layout) before
1695
+ * Base64Url encoding.
1696
+ *
1697
+ * Tradeoff: better insertion locality / index performance for huge datasets vs
1698
+ * leaking creation time everywhere the Id appears. Evolu uses {@link createId}
1699
+ * by default to avoid activity leakage; choose this only if you explicitly
1700
+ * accept timestamp exposure.
1701
+ *
1702
+ * ### Example
1703
+ *
1704
+ * ```ts
1705
+ * const id = createIdAsUuidv7({ randomBytes, time });
1706
+ * const todoId = createIdAsUuidv7<"Todo">({ randomBytes, time });
1707
+ * ```
1708
+ */
1709
+ export const createIdAsUuidv7 = <B extends string = never>(
1710
+ deps: RandomBytesDep & TimeDep,
1711
+ ): [B] extends [never] ? Id : Id & Brand<B> => {
1712
+ const id = deps.randomBytes.create(16);
1713
+
1714
+ const timestamp = globalThis.BigInt(deps.time.now());
1715
+
1716
+ id[0] = globalThis.Number((timestamp >> 40n) & 0xffn);
1717
+ id[1] = globalThis.Number((timestamp >> 32n) & 0xffn);
1718
+ id[2] = globalThis.Number((timestamp >> 24n) & 0xffn);
1719
+ id[3] = globalThis.Number((timestamp >> 16n) & 0xffn);
1720
+ id[4] = globalThis.Number((timestamp >> 8n) & 0xffn);
1721
+ id[5] = globalThis.Number(timestamp & 0xffn);
1722
+
1723
+ id[6] = (id[6] & 0x0f) | 0x70;
1724
+ id[8] = (id[8] & 0x3f) | 0x80;
1725
+
1726
+ return id as unknown as [B] extends [never] ? Id : Id & Brand<B>;
1727
+ };
1728
+
1707
1729
  /**
1708
1730
  * Creates a branded {@link Id} Type for a table's primary key.
1709
1731
  *