@evolu/common 6.0.1-preview.3 → 6.0.1-preview.5

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.
@@ -1,86 +1,27 @@
1
1
  import { pack } from "msgpackr";
2
+ import { assert } from "../Assert.js";
2
3
  import { mapObject, objectToEntries } from "../Object.js";
3
4
  import { err, ok } from "../Result.js";
4
5
  import { SqliteBoolean } from "../Sqlite.js";
5
- import { brand, createTypeErrorFormatter, DateIsoString, EvoluType, nullableToOptional, nullOr, object, omit, optional, record, Unknown, } from "../Type.js";
6
+ import { brand, createTypeErrorFormatter, DateIsoString, nullableToOptional, nullOr, object, omit, optional, } from "../Type.js";
7
+ import { DbSchema } from "./Db.js";
6
8
  import { createIndexes } from "./Kysely.js";
7
- import { Base64Url256, } from "./Protocol.js";
8
- export const DefaultColumns = object({
9
- createdAt: DateIsoString,
10
- updatedAt: DateIsoString,
11
- isDeleted: nullOr(SqliteBoolean),
12
- });
13
- const isDefaultColumnName = (value) => value === "createdAt" || value === "updatedAt" || value === "isDeleted";
14
- /**
15
- * Valid {@link EvoluSchema}.
16
- *
17
- * - Table and column names must be Base64Url strings.
18
- * - Each table must include an `id` column of type {@link Id}.
19
- * - Default column names (`createdAt`, `updatedAt`, `isDeleted`) are not allowed.
20
- */
21
- export const ValidEvoluSchema = brand("ValidEvoluSchema", record(Base64Url256, object({ id: EvoluType }, record(Base64Url256, Unknown))), (value) => {
22
- for (const tableName in value) {
23
- for (const columnName in value[tableName]) {
24
- if (isDefaultColumnName(columnName)) {
25
- return err({
26
- type: "ValidEvoluSchema",
27
- value,
28
- reason: {
29
- kind: "DefaultColumnError",
30
- tableName,
31
- columnName,
32
- },
33
- });
34
- }
35
- }
36
- }
37
- return ok(value);
38
- });
39
- /**
40
- * Asserts that the given value is {@link ValidEvoluSchema}.
41
- *
42
- * Throws an error if the value is not a valid Evolu schema.
43
- */
44
- export const assertValidEvoluSchema = (value) => {
45
- const validEvoluSchema = ValidEvoluSchema.fromUnknown(value);
46
- if (!validEvoluSchema.ok) {
47
- const message = formatValidEvoluSchemaError(validEvoluSchema.error);
48
- throw new Error(`Invalid Evolu schema: ${message}`);
49
- }
50
- return validEvoluSchema.value;
51
- };
52
- const formatValidEvoluSchemaError = (error) => {
53
- if (error.type === "Record") {
54
- if (error.reason.kind === "Key") {
55
- return `The table "${error.reason.key}" has invalid name. A table name must be Base64Url256 string (A-Z, a-z, 0-9, -, _).`;
56
- }
57
- if (error.reason.kind === "Value" &&
58
- error.reason.error.reason.kind === "Props" &&
59
- error.reason.error.reason.errors.id?.type === "EvoluType") {
60
- return `The table "${error.reason.key}" has invalid ID column. Check examples.`;
61
- }
62
- if (error.reason.kind === "Value" &&
63
- error.reason.error.reason.kind === "IndexKey") {
64
- return `The table "${error.reason.key}" has invalid column name "${error.reason.error.reason.key}". A column name must be Base64Url256 string (A-Z, a-z, 0-9, -, _).`;
65
- }
66
- }
67
- if (error.type === "ValidEvoluSchema") {
68
- return `The table "${error.reason.tableName}" uses reserved column name "${error.reason.columnName}". Reserved column names are: createdAt, updatedAt, isDeleted.`;
69
- }
70
- return JSON.stringify(error, null, 2);
71
- };
72
- export const validEvoluSchemaToDbSchema = (validEvoluSchema, indexes) => {
73
- const tables = objectToEntries(validEvoluSchema).map(([tableName, table]) => ({
9
+ export const evoluSchemaToDbSchema = (schema, indexes) => {
10
+ const tables = objectToEntries(schema).map(([tableName, table]) => ({
74
11
  name: tableName,
75
12
  columns: objectToEntries(table)
76
13
  .filter(([k]) => k !== "id")
77
14
  .map(([k]) => k),
78
15
  }));
79
- return {
80
- tables,
81
- indexes: createIndexes(indexes),
82
- };
16
+ const dbSchema = { tables, indexes: createIndexes(indexes) };
17
+ assert(DbSchema.is(dbSchema), "Invalid EvoluSchema: Table and column names must use only characters A-Za-z0-9_- and be at most 256 characters long.");
18
+ return dbSchema;
83
19
  };
20
+ export const DefaultColumns = object({
21
+ createdAt: DateIsoString,
22
+ updatedAt: DateIsoString,
23
+ isDeleted: nullOr(SqliteBoolean),
24
+ });
84
25
  /**
85
26
  * Evolu has to limit the maximum mutation size. Otherwise, sync couldn't use
86
27
  * the {@link maxProtocolMessageRangesSize}. The max size is 640KB in bytes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/common",
3
- "version": "6.0.1-preview.3",
3
+ "version": "6.0.1-preview.5",
4
4
  "description": "TypeScript library and local-first framework",
5
5
  "keywords": [
6
6
  "evolu",
package/src/Crypto.ts CHANGED
@@ -189,3 +189,16 @@ export const padmePaddedLength = (length: NonNegativeInt): NonNegativeInt => {
189
189
  export const padmePaddingLength = (length: NonNegativeInt): NonNegativeInt => {
190
190
  return (padmePaddedLength(length) - length) as NonNegativeInt;
191
191
  };
192
+
193
+ /**
194
+ * Performs a timing-safe comparison of two Uint8Arrays. Returns true if they
195
+ * are equal, false otherwise. Takes constant time regardless of where the
196
+ * arrays differ.
197
+ *
198
+ * @see https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b
199
+ */
200
+ export type TimingSafeEqual = (a: Uint8Array, b: Uint8Array) => boolean;
201
+
202
+ export interface TimingSafeEqualDep {
203
+ readonly timingSafeEqual: TimingSafeEqual;
204
+ }
package/src/Evolu/Db.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  SqliteValue,
35
35
  } from "../Sqlite.js";
36
36
  import { TimeDep } from "../Time.js";
37
- import { Id, Mnemonic, object, SimpleName, String } from "../Type.js";
37
+ import { array, Id, Mnemonic, object, SimpleName, String } from "../Type.js";
38
38
  import {
39
39
  createInitializedWorker,
40
40
  Worker,
@@ -97,19 +97,21 @@ import {
97
97
  timestampToTimestampString,
98
98
  } from "./Timestamp.js";
99
99
 
100
- export interface DbSchema {
101
- readonly tables: ReadonlyArray<DbTable>;
102
- readonly indexes: ReadonlyArray<DbIndex>;
103
- }
104
-
105
- export interface DbTable {
106
- readonly name: Base64Url256;
107
- readonly columns: ReadonlyArray<Base64Url256>;
108
- }
100
+ export const DbTable = object({
101
+ name: Base64Url256,
102
+ columns: array(Base64Url256),
103
+ });
104
+ export type DbTable = typeof DbTable.Type;
109
105
 
110
106
  export const DbIndex = object({ name: String, sql: String });
111
107
  export type DbIndex = typeof DbIndex.Type;
112
108
 
109
+ export const DbSchema = object({
110
+ tables: array(DbTable),
111
+ indexes: array(DbIndex),
112
+ });
113
+ export type DbSchema = typeof DbSchema.Type;
114
+
113
115
  export type DbWorker = Worker<DbWorkerInput, DbWorkerOutput>;
114
116
 
115
117
  export type CreateDbWorker = (name: SimpleName) => DbWorker;
@@ -803,6 +805,8 @@ const initializeDb =
803
805
  strict;
804
806
  `,
805
807
 
808
+ // Index for reading database changes by owner and timestamp.
809
+ // Timestamp always corresponds to a DbChange.
806
810
  sql`
807
811
  create index evolu_history_ownerId_timestamp on evolu_history (
808
812
  "ownerId",
@@ -1189,7 +1193,6 @@ const createClientStorage =
1189
1193
 
1190
1194
  writeMessages: (_ownerId, messages) => {
1191
1195
  // TODO: Get owner by _ownerId when we support more.
1192
- // Use ownerId!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1193
1196
  const owner = deps.ownerRowRef.get();
1194
1197
  const decodedAndDecryptedMessages: Array<CrdtMessage> = [];
1195
1198
 
@@ -1,5 +1,5 @@
1
1
  import { isNonEmptyArray, isNonEmptyReadonlyArray } from "../Array.js";
2
- import { assertNonEmptyArray } from "../Assert.js";
2
+ import { assert, assertNonEmptyArray } from "../Assert.js";
3
3
  import { createCallbacks } from "../Callbacks.js";
4
4
  import { ConsoleDep } from "../Console.js";
5
5
  import { SymmetricCryptoDecryptError } from "../Crypto.js";
@@ -44,9 +44,9 @@ import {
44
44
  SubscribedQueries,
45
45
  } from "./Query.js";
46
46
  import {
47
- assertValidEvoluSchema,
48
47
  CreateQuery,
49
48
  EvoluSchema,
49
+ evoluSchemaToDbSchema,
50
50
  insertable,
51
51
  Mutation,
52
52
  MutationKind,
@@ -54,7 +54,7 @@ import {
54
54
  MutationOptions,
55
55
  updateable,
56
56
  upsertable,
57
- validEvoluSchemaToDbSchema,
57
+ ValidateSchema,
58
58
  ValidMutationSize,
59
59
  ValidMutationSizeError,
60
60
  } from "./Schema.js";
@@ -431,9 +431,7 @@ let tabId: Id | null = null;
431
431
  export const createEvolu =
432
432
  (deps: EvoluDeps) =>
433
433
  <S extends EvoluSchema>(
434
- // TODO: Validate missing Id, unsupported types, used default types via TS types
435
- // with type errors messages as we had it in the old Evolu.
436
- schema: S,
434
+ schema: ValidateSchema<S> extends never ? S : ValidateSchema<S>,
437
435
  partialConfig: Partial<EvoluConfigWithInitialData<S>> = {},
438
436
  ): Evolu<S> => {
439
437
  const config = { ...defaultConfig, ...partialConfig };
@@ -441,11 +439,14 @@ export const createEvolu =
441
439
  let evolu = evoluInstances.get(config.name);
442
440
 
443
441
  if (evolu == null) {
444
- evolu = createEvoluInstance(deps)(schema, config as IntentionalNever);
442
+ evolu = createEvoluInstance(deps)(
443
+ schema as EvoluSchema,
444
+ config as IntentionalNever,
445
+ );
445
446
  evoluInstances.set(config.name, evolu);
446
447
  } else {
447
448
  // Hot reloading. Note that indexes are intentionally omitted.
448
- evolu.ensureSchema(schema);
449
+ evolu.ensureSchema(schema as EvoluSchema);
449
450
  }
450
451
 
451
452
  return evolu as IntentionalNever;
@@ -554,10 +555,7 @@ const createEvoluInstance =
554
555
  }
555
556
  });
556
557
 
557
- const dbSchema = validEvoluSchemaToDbSchema(
558
- assertValidEvoluSchema(schema),
559
- indexes,
560
- );
558
+ const dbSchema = evoluSchemaToDbSchema(schema, indexes);
561
559
 
562
560
  const mutationTypesCache = new Map<
563
561
  MutationKind,
@@ -592,21 +590,17 @@ const createEvoluInstance =
592
590
  if (initialData)
593
591
  initialData({
594
592
  insert: (table, props) => {
595
- const Type = getMutationType(table, "insert");
596
593
  const id = createId(deps);
594
+ const values = getMutationType(table, "insert").fromUnknown(props);
597
595
 
598
- const result = Type.fromUnknown(props);
599
-
600
- if (result.ok) {
601
- initialDataDbChanges.push({
602
- id,
603
- table,
604
- values: result.value,
605
- } as unknown as DbChange);
596
+ if (values.ok) {
597
+ const dbChange = { table, id, values: values.value };
598
+ assertValidDbChange(dbChange);
599
+ initialDataDbChanges.push(dbChange);
606
600
  return ok({ id });
607
601
  }
608
602
 
609
- return result;
603
+ return values;
610
604
  },
611
605
  });
612
606
 
@@ -654,14 +648,14 @@ const createEvoluInstance =
654
648
  } else {
655
649
  // Remove `id` from values.
656
650
  const { id: _id, ...values } = result.value;
657
- // EvoluSchema Types ensure valid types.
658
- const change = { table, id, values } as unknown as DbChange;
659
- mutateMicrotaskQueue.push([change, options?.onComplete]);
651
+ const dbChange = { table, id, values };
652
+ assertValidDbChange(dbChange);
653
+ mutateMicrotaskQueue.push([dbChange, options?.onComplete]);
660
654
  }
661
655
 
662
656
  if (mutateMicrotaskQueue.length === 1)
663
657
  queueMicrotask(() => {
664
- const changes = [];
658
+ const changes: Array<DbChange> = [];
665
659
  const onCompletes = [];
666
660
 
667
661
  for (const [change, onComplete] of mutateMicrotaskQueue) {
@@ -807,6 +801,7 @@ const createEvoluInstance =
807
801
  const onCompleteId = callbacks.register(() => {
808
802
  resolve();
809
803
  });
804
+
810
805
  dbWorker.postMessage({
811
806
  type: "reset",
812
807
  onCompleteId,
@@ -822,11 +817,8 @@ const createEvoluInstance =
822
817
 
823
818
  ensureSchema: (schema) => {
824
819
  mutationTypesCache.clear();
825
- const validSchema = assertValidEvoluSchema(schema);
826
- dbWorker.postMessage({
827
- type: "ensureDbSchema",
828
- dbSchema: validEvoluSchemaToDbSchema(validSchema),
829
- });
820
+ const dbSchema = evoluSchemaToDbSchema(schema);
821
+ dbWorker.postMessage({ type: "ensureDbSchema", dbSchema });
830
822
  },
831
823
 
832
824
  exportDatabase: () => {
@@ -945,3 +937,14 @@ const createLoadingPromises = (
945
937
 
946
938
  return loadingPromises;
947
939
  };
940
+
941
+ const assertValidDbChange: (dbChange: {
942
+ table: string;
943
+ id: Id;
944
+ values: unknown;
945
+ }) => asserts dbChange is DbChange = (dbChange) => {
946
+ assert(
947
+ DbChange.is(dbChange),
948
+ `Failed to create DbChange for table "${dbChange.table}". If you see this message, you either disabled EvoluSchema validation or Evolu has a bug - please report it.`,
949
+ );
950
+ };
@@ -144,7 +144,7 @@ import {
144
144
  } from "../Crypto.js";
145
145
  import { eqArrayNumber } from "../Eq.js";
146
146
  import { computeBalancedBuckets } from "../Number.js";
147
- import { objectToEntries, ReadonlyRecord } from "../Object.js";
147
+ import { objectToEntries } from "../Object.js";
148
148
  import { err, ok, Result } from "../Result.js";
149
149
  import { SqliteValue } from "../Sqlite.js";
150
150
  import {
@@ -157,7 +157,9 @@ import {
157
157
  NanoId,
158
158
  NonNegativeInt,
159
159
  Number,
160
+ object,
160
161
  PositiveInt,
162
+ record,
161
163
  } from "../Type.js";
162
164
  import { Brand, Predicate } from "../Types.js";
163
165
  import {
@@ -293,15 +295,23 @@ export interface CrdtMessage {
293
295
  readonly change: DbChange;
294
296
  }
295
297
 
298
+ /**
299
+ * Base64Url string with maximum length of 256 characters. Encoding strings as
300
+ * Base64UrlString saves up to 25% in size compared to regular strings.
301
+ */
302
+ export const Base64Url256 = maxLength(256)(Base64Url);
303
+ export type Base64Url256 = typeof Base64Url256.Type;
304
+
296
305
  /**
297
306
  * A DbChange is a change to a table row. Together with a unique
298
307
  * {@link Timestamp}, it forms a {@link CrdtMessage}.
299
308
  */
300
- export interface DbChange {
301
- readonly table: Base64Url256;
302
- readonly id: Id;
303
- readonly values: ReadonlyRecord<Base64Url256, SqliteValue>;
304
- }
309
+ export const DbChange = object({
310
+ table: Base64Url256,
311
+ id: Id,
312
+ values: record(Base64Url256, SqliteValue),
313
+ });
314
+ export type DbChange = typeof DbChange.Type;
305
315
 
306
316
  export const RangeType = {
307
317
  Fingerprint: 1,
@@ -1434,13 +1444,6 @@ export const ownerIdToBinaryOwnerId = (ownerId: OwnerId): BinaryOwnerId =>
1434
1444
  export const binaryOwnerIdToOwnerId = (binaryOwnerId: BinaryOwnerId): OwnerId =>
1435
1445
  decodeOwnerId(createBuffer(binaryOwnerId));
1436
1446
 
1437
- /**
1438
- * Base64Url string with maximum length of 256 characters. Encoding strings as
1439
- * Base64UrlString saves up to 25% in size compared to regular strings.
1440
- */
1441
- export const Base64Url256 = maxLength(256)(Base64Url);
1442
- export type Base64Url256 = typeof Base64Url256.Type;
1443
-
1444
1447
  /**
1445
1448
  * Union type for all variants of Base64Url strings with limited length. All
1446
1449
  * these types use Base64Url alphabet and are < 256 characters.
@@ -1,6 +1,6 @@
1
1
  import { isNonEmptyReadonlyArray } from "../Array.js";
2
2
  import { ConsoleConfig } from "../Console.js";
3
- import { eqArrayNumber } from "../Eq.js";
3
+ import { TimingSafeEqualDep } from "../Crypto.js";
4
4
  import { ok, Result } from "../Result.js";
5
5
  import { sql, SqliteError } from "../Sqlite.js";
6
6
  import { SimpleName } from "../Type.js";
@@ -19,8 +19,10 @@ export interface RelayConfig extends ConsoleConfig {
19
19
  readonly name?: SimpleName;
20
20
  }
21
21
 
22
+ export type RelaySqliteStorageDeps = SqliteStorageDeps & TimingSafeEqualDep;
23
+
22
24
  export const createRelayStorage =
23
- (deps: SqliteStorageDeps) =>
25
+ (deps: RelaySqliteStorageDeps) =>
24
26
  (options: CreateSqliteStorageBaseOptions): Result<Storage, SqliteError> => {
25
27
  const sqliteStorageBase = createSqliteStorageBase(deps)(options);
26
28
  if (!sqliteStorageBase.ok) return sqliteStorageBase;
@@ -87,7 +89,7 @@ export const createRelayStorage =
87
89
  return true;
88
90
  }
89
91
 
90
- return eqArrayNumber(rows[0].writeKey, writeKey);
92
+ return deps.timingSafeEqual(rows[0].writeKey, writeKey);
91
93
  },
92
94
 
93
95
  writeMessages: (ownerId, messages) => {