@evolu/common 8.6.1 → 8.7.0

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.
package/src/Sqlite.ts CHANGED
@@ -8,19 +8,19 @@ import type { Brand } from "./Brand.ts";
8
8
  import { bytesToHex, hexToBytes } from "./Buffer.ts";
9
9
  import type { EncryptionKey } from "./Crypto.ts";
10
10
  import type { Eq } from "./Eq.ts";
11
- import { createEqObject, eqArrayNumber, eqString } from "./Eq.ts";
11
+ import { createEqObject, eqString, eqUint8Array } from "./Eq.ts";
12
12
  import { disposable } from "./Function.ts";
13
13
  import { createMutableRecord, objectToEntries } from "./Object.ts";
14
14
  import type { Result } from "./Result.ts";
15
15
  import { ok } from "./Result.ts";
16
- import { testCreateRun, type Task, type TestRunDep } from "./Task.ts";
16
+ import { type Task, testCreateRun, type TestRunDep } from "./Task.ts";
17
17
  import {
18
18
  array,
19
19
  type ArrayType,
20
+ FiniteNumber,
20
21
  type InferType,
21
22
  type Name,
22
23
  Null,
23
- Number,
24
24
  object,
25
25
  type ObjectType,
26
26
  record,
@@ -79,7 +79,7 @@ export interface SqliteTransaction {
79
79
  /** Represents a SQL query to be executed on a {@link Sqlite} database. */
80
80
  export interface SqliteQuery {
81
81
  readonly sql: SafeSql;
82
- readonly parameters: Array<SqliteValue>;
82
+ readonly parameters: SqliteQueryParameters;
83
83
  readonly options?: SqliteQueryOptions;
84
84
  }
85
85
 
@@ -92,20 +92,38 @@ export type SafeSql = string & Brand<"SafeSql">;
92
92
  /**
93
93
  * A value that can be stored in {@link Sqlite}.
94
94
  *
95
+ * Numeric values use {@link FiniteNumber} because JavaScript numbers also
96
+ * include `NaN` and infinities. SQLite binds `NaN` as `NULL`, while JSON
97
+ * serialization converts all non-finite numbers to `null`. Restricting numbers
98
+ * to finite values preserves them across storage and query serialization.
99
+ *
95
100
  * Note that Evolu can't support Int64 because expo-sqlite (and some others) do
96
101
  * not support it.
97
102
  */
98
103
  export const SqliteValue = /*#__PURE__*/ union(
104
+ FiniteNumber,
99
105
  Null,
100
106
  String,
101
- Number,
102
107
  Uint8Array,
103
108
  );
104
109
  export type SqliteValue = typeof SqliteValue.Output;
105
110
 
106
- /** Equality comparison for {@link SqliteValue}. */
107
- export const eqSqliteValue: Eq<SqliteValue> = (x, y) =>
108
- Uint8Array.is(x) && Uint8Array.is(y) ? eqArrayNumber(x, y) : x === y;
111
+ /** Parameters of a {@link SqliteQuery}. */
112
+ export const SqliteQueryParameters = /*#__PURE__*/ array(SqliteValue);
113
+ export type SqliteQueryParameters = typeof SqliteQueryParameters.Output;
114
+
115
+ /**
116
+ * A value used in {@link sql} template parameters and {@link Sqlite} query
117
+ * results.
118
+ *
119
+ * It differs from {@link SqliteValue} only by using `number` instead of
120
+ * {@link FiniteNumber}.
121
+ */
122
+ export type SqliteValueInput = typeof SqliteValue.Input;
123
+
124
+ /** Equality comparison for {@link SqliteValueInput}. */
125
+ export const eqSqliteValue: Eq<SqliteValueInput> = (x, y) =>
126
+ Uint8Array.is(x) && Uint8Array.is(y) ? eqUint8Array(x, y) : x === y;
109
127
 
110
128
  /** Options for configuring {@link SqliteQuery} execution behavior. */
111
129
  export interface SqliteQueryOptions {
@@ -139,7 +157,7 @@ export const sqliteQueryToSqliteQueryString = (
139
157
  query: SqliteQuery,
140
158
  ): SqliteQueryString => {
141
159
  const params = query.parameters.map((value) =>
142
- value instanceof globalThis.Uint8Array
160
+ Uint8Array.is(value)
143
161
  ? (["b", bytesToHex(value)] as const)
144
162
  : (["j", value] as const),
145
163
  );
@@ -157,7 +175,9 @@ export const sqliteQueryStringToSqliteQuery = (
157
175
  ): SqliteQuery => {
158
176
  const [sql, paramsArr, optionsArr] = JSON.parse(query) as [
159
177
  SafeSql,
160
- Array<readonly ["b", string] | readonly ["j", string | number | null]>,
178
+ Array<
179
+ readonly ["b", string] | readonly ["j", string | FiniteNumber | null]
180
+ >,
161
181
  Array<Array<string | number | null>>,
162
182
  ];
163
183
 
@@ -184,9 +204,9 @@ export interface SqliteExecResult<R extends SqliteRow = SqliteRow> {
184
204
 
185
205
  /**
186
206
  * A row returned from a {@link Sqlite} query, mapping column names to
187
- * {@link SqliteValue}.
207
+ * {@link SqliteValueInput}.
188
208
  */
189
- export type SqliteRow = Record<string, SqliteValue>;
209
+ export type SqliteRow = Record<string, SqliteValueInput>;
190
210
 
191
211
  /**
192
212
  * SQLite driver interface.
@@ -423,7 +443,7 @@ export interface RawSql extends Typed<"RawSql"> {
423
443
  }
424
444
 
425
445
  /** A parameter accepted by the {@link sql} tagged template. */
426
- export type SqlTemplateParam = SqliteValue | SqlIdentifier | RawSql;
446
+ export type SqlTemplateParam = SqliteValueInput | SqlIdentifier | RawSql;
427
447
 
428
448
  /**
429
449
  * Creates a safe SQL query using a tagged template literal.
@@ -484,7 +504,7 @@ export const sql = (
484
504
  sql += param.sql;
485
505
  } else {
486
506
  sql += "?";
487
- values.push(param);
507
+ values.push(SqliteValue.orThrow(param));
488
508
  }
489
509
  }
490
510
  }
@@ -647,8 +667,6 @@ export const getSqliteSnapshot = (deps: SqliteDep): SqliteSnapshot => {
647
667
  *
648
668
  * See: https://www.sqlite.org/quirks.html#no_separate_boolean_datatype
649
669
  *
650
- * ## Tips
651
- *
652
670
  * - Use {@link sqliteTrue} and {@link sqliteFalse} constants for better
653
671
  * readability.
654
672
  * - Use {@link booleanToSqliteBoolean} and {@link sqliteBooleanToBoolean} for
@@ -45,6 +45,7 @@ import { callback, type Run, type Task } from "../Task.ts";
45
45
  import { Millis, millisToDateIso, type TimeDep } from "../Time.ts";
46
46
  import {
47
47
  assertType,
48
+ type FiniteNumber,
48
49
  Id,
49
50
  IdBytes,
50
51
  idBytesToId,
@@ -942,7 +943,8 @@ const dbChangeToColumns = (change: DbChange, now: Millis) => {
942
943
  if (change.isDelete != null) {
943
944
  values = appendToArray(values, [
944
945
  "isDeleted",
945
- booleanToSqliteBoolean(change.isDelete),
946
+ // SQLite boolean values are fixed to the finite numbers 0 and 1.
947
+ booleanToSqliteBoolean(change.isDelete) as FiniteNumber,
946
948
  ]);
947
949
  }
948
950
 
@@ -170,14 +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
- * - Replace try-catch with Result + new Error (to preserve stacktraces). Measure
174
- * Result overhead, it should be super small.
175
173
  * - Allow clients to broadcast messages that are not persisted by relays. This
176
174
  * would enable real-time ephemeral data (like cursor positions, typing
177
175
  * indicators) to be forwarded by relays without storage overhead.
178
176
  */
179
177
 
180
- import { Packr } from "msgpackr";
181
178
  import {
182
179
  createMutableArray,
183
180
  isNonEmptyArray,
@@ -190,6 +187,8 @@ import {
190
187
  bytesToHex,
191
188
  bytesToUtf8,
192
189
  createBuffer,
190
+ decodeJsonValue,
191
+ encodeJsonValue,
193
192
  hexToBytes,
194
193
  utf8ToBytes,
195
194
  } from "../Buffer.ts";
@@ -217,6 +216,7 @@ import {
217
216
  base64UrlToUint8Array,
218
217
  between,
219
218
  DateIso,
219
+ FiniteNumber,
220
220
  Id,
221
221
  IdBytes,
222
222
  idBytesToId,
@@ -226,7 +226,6 @@ import {
226
226
  Json,
227
227
  jsonToJsonValue,
228
228
  NonNegativeInt,
229
- Number,
230
229
  onePositiveInt,
231
230
  PositiveInt,
232
231
  type Typed,
@@ -271,13 +270,7 @@ import {
271
270
  timestampToTimestampBytes,
272
271
  } from "./Timestamp.ts";
273
272
 
274
- /**
275
- * Evolu uses MessagePack for numbers and JSONs.
276
- *
277
- * - `variableMapSize: true` - More compact maps, ~5-10% slower encoding
278
- * - `useRecords: false` - Standard MessagePack without extensions
279
- */
280
- const packr = new Packr({ variableMapSize: true, useRecords: false });
273
+ const jsonBuffer = createBuffer();
281
274
 
282
275
  const minProtocolMessageMaxSize = 1_000_000;
283
276
  const maxProtocolMessageMaxSize = 100_000_000;
@@ -1775,33 +1768,16 @@ const decodeId = (buffer: Buffer): Id => {
1775
1768
  };
1776
1769
 
1777
1770
  /**
1778
- * Evolu uses MessagePack to handle all number variants except for
1779
- * NonNegativeInt. For NonNegativeInt, Evolu provides more efficient encoding.
1771
+ * Evolu uses MessagePack to handle finite numbers except for NonNegativeInt.
1772
+ * For NonNegativeInt, Evolu provides more efficient encoding.
1780
1773
  */
1781
- export const encodeNumber = (buffer: Buffer, number: number): void => {
1782
- buffer.extend(packr.pack(number));
1774
+ export const encodeNumber = (buffer: Buffer, number: FiniteNumber): void => {
1775
+ encodeJsonValue(buffer, number);
1783
1776
  };
1784
1777
 
1785
- export const decodeNumber = (buffer: Buffer): number => {
1786
- let number: unknown;
1787
- let end: unknown;
1788
-
1789
- packr.unpackMultiple(
1790
- buffer.unwrap(),
1791
- (n: unknown, _: unknown, e: unknown) => {
1792
- number = n;
1793
- end = e;
1794
- return false;
1795
- },
1796
- );
1797
-
1798
- const endResult = NonNegativeInt.fromUnknown(end);
1799
- if (!endResult.ok) throw new ProtocolDecodeError(endResult.error.type);
1800
-
1801
- const numberResult = Number.fromUnknown(number);
1778
+ export const decodeNumber = (buffer: Buffer): FiniteNumber => {
1779
+ const numberResult = FiniteNumber.fromUnknown(decodeJsonValue(buffer));
1802
1780
  if (!numberResult.ok) throw new ProtocolDecodeError(numberResult.error.type);
1803
-
1804
- buffer.shiftN(endResult.value);
1805
1781
  return numberResult.value;
1806
1782
  };
1807
1783
 
@@ -2140,7 +2116,7 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
2140
2116
  buffer,
2141
2117
  ProtocolValueType.DateIsoWithNegativeTime,
2142
2118
  );
2143
- encodeNumber(buffer, time);
2119
+ encodeNumber(buffer, FiniteNumber.orThrow(time));
2144
2120
  }
2145
2121
  return;
2146
2122
  }
@@ -2157,10 +2133,16 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
2157
2133
  // Some valid JSON strings like "-0E0" get normalized to "0" during parsing,
2158
2134
  // which would cause data corruption if we don't verify round-trip safety.
2159
2135
  if (json.ok && JSON.stringify(jsonToJsonValue(json.value)) === value) {
2160
- const jsonBytes = packr.pack(jsonToJsonValue(json.value));
2161
- encodeNonNegativeInt(buffer, ProtocolValueType.Json);
2162
- encodeLength(buffer, jsonBytes);
2163
- buffer.extend(jsonBytes);
2136
+ jsonBuffer.reset();
2137
+ try {
2138
+ encodeJsonValue(jsonBuffer, jsonToJsonValue(json.value));
2139
+ const jsonBytes = jsonBuffer.unwrap();
2140
+ encodeNonNegativeInt(buffer, ProtocolValueType.Json);
2141
+ encodeLength(buffer, jsonBytes);
2142
+ buffer.extend(jsonBytes);
2143
+ } finally {
2144
+ jsonBuffer.reset();
2145
+ }
2164
2146
  return;
2165
2147
  }
2166
2148
 
@@ -2179,7 +2161,9 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
2179
2161
  }
2180
2162
 
2181
2163
  case "number": {
2182
- if (NonNegativeInt.is(value)) {
2164
+ // Negative zero is a non-negative integer in JavaScript, but integer
2165
+ // encoding would lose its observable sign.
2166
+ if (!globalThis.Object.is(value, -0) && NonNegativeInt.is(value)) {
2183
2167
  if (isSmallInt(value)) {
2184
2168
  encodeNonNegativeInt(buffer, value);
2185
2169
  return;
@@ -2229,8 +2213,12 @@ export const decodeSqliteValue = (buffer: Buffer): SqliteValue => {
2229
2213
 
2230
2214
  case ProtocolValueType.Json: {
2231
2215
  const length = decodeLength(buffer);
2232
- const bytes = buffer.shiftN(length);
2233
- return JSON.stringify(packr.unpack(bytes));
2216
+ const lengthBeforeDecoding = buffer.getLength();
2217
+ const value = decodeJsonValue(buffer);
2218
+ if (lengthBeforeDecoding - buffer.getLength() !== length) {
2219
+ throw new ProtocolDecodeError("Invalid JSON MessagePack length");
2220
+ }
2221
+ return JSON.stringify(value);
2234
2222
  }
2235
2223
 
2236
2224
  case ProtocolValueType.DateIsoWithNonNegativeTime:
@@ -26,8 +26,8 @@ import type { Brand } from "../Brand.ts";
26
26
  import { createRandomBytes } from "../Crypto.ts";
27
27
  import type { ReadonlyRecord } from "../Object.ts";
28
28
  import { createMutableRecord, isPlainObject } from "../Object.ts";
29
- import type { SqliteQueryString } from "../Sqlite.ts";
30
- import { eqSqliteValue, SqliteValue } from "../Sqlite.ts";
29
+ import type { SqliteQueryString, SqliteValueInput } from "../Sqlite.ts";
30
+ import { eqSqliteValue } from "../Sqlite.ts";
31
31
  import { createId, String } from "../Type.ts";
32
32
  import type { EvoluSchema } from "./Schema.ts";
33
33
 
@@ -107,7 +107,7 @@ export type InferRow<T extends Query> =
107
107
 
108
108
  export interface Row {
109
109
  readonly [key: string]:
110
- | SqliteValue
110
+ | SqliteValueInput
111
111
  // for evoluJsonObjectFrom
112
112
  | Row
113
113
  // for evoluJsonArrayFrom
@@ -400,8 +400,8 @@ export const makePatches = (
400
400
  for (const key in previousRow)
401
401
  if (
402
402
  !eqSqliteValue(
403
- previousRow[key] as SqliteValue,
404
- nextRow[key] as SqliteValue,
403
+ previousRow[key] as SqliteValueInput,
404
+ nextRow[key] as SqliteValueInput,
405
405
  )
406
406
  ) {
407
407
  replaceAtPatches.push({ op: "replaceAt", value: nextRow, index: i });
@@ -19,11 +19,14 @@ import {
19
19
  type SqliteIndex,
20
20
  type SqliteQuery,
21
21
  type SqliteQueryOptions,
22
+ SqliteQueryParameters,
22
23
  type SqliteSchema,
23
24
  SqliteValue,
24
25
  } from "../Sqlite.ts";
25
26
  import {
27
+ assertType,
26
28
  DateIso,
29
+ type FiniteNumber,
27
30
  type Id,
28
31
  IdBytes,
29
32
  type InferType,
@@ -54,6 +57,11 @@ export type AnyStandardSchemaV1 = StandardSchemaV1<any, any>;
54
57
  * Column types are Standard Schema v1 compatible — use Evolu Type, Zod,
55
58
  * Valibot, ArkType, or any library that implements Standard Schema.
56
59
  *
60
+ * Each column schema's inferred output must be compatible with SQLite. Numeric
61
+ * outputs must be assignable to {@link FiniteNumber}. Standard Schema does not
62
+ * standardize nominal brands, so a library-specific finite validator whose
63
+ * output remains `number` must brand its validated output compatibly.
64
+ *
57
65
  * Table schema defines columns that are required for table rows. For optional
58
66
  * columns, use a schema whose output type includes `null`.
59
67
  *
@@ -63,7 +71,9 @@ export type AnyStandardSchemaV1 = StandardSchemaV1<any, any>;
63
71
  * import * as z from "zod";
64
72
  * import {
65
73
  * assertOk,
74
+ * assertType,
66
75
  * assertTrue,
76
+ * type FiniteNumber,
67
77
  * id,
68
78
  * NonEmptyTrimmedString100,
69
79
  * nullOr,
@@ -84,10 +94,17 @@ export type AnyStandardSchemaV1 = StandardSchemaV1<any, any>;
84
94
  * assertOk(Schema.todo.title.fromUnknown("Write docs"), "Write docs");
85
95
  *
86
96
  * // Zod, or another Standard Schema library
97
+ * // Zod 4 numbers are finite by default; Evolu Type Number models all JavaScript numbers.
98
+ * const ZodFiniteNumber = z
99
+ * .number()
100
+ * .transform((value): FiniteNumber => value as FiniteNumber);
101
+ * assertType<FiniteNumber, z.output<typeof ZodFiniteNumber>>();
102
+ *
87
103
  * const ZodSchema = {
88
104
  * todo: {
89
105
  * id: TodoId,
90
106
  * title: z.string().min(1).max(100),
107
+ * position: ZodFiniteNumber,
91
108
  * isCompleted: z.union([z.literal(0), z.literal(1)]).nullable(),
92
109
  * },
93
110
  * };
@@ -377,11 +394,10 @@ export type ValidateColumnTypes<S extends EvoluSchema> =
377
394
  ? TableName extends keyof S
378
395
  ? keyof S[TableName] extends infer ColumnName
379
396
  ? ColumnName extends keyof S[TableName]
380
- ? StandardSchemaV1.InferOutput<
381
- S[TableName][ColumnName]
382
- > extends SqliteValue
397
+ ? StandardSchemaV1.InferOutput<S[TableName][ColumnName]> extends
398
+ SqliteValue | SqliteBoolean
383
399
  ? never
384
- : SchemaValidationError<`Table "${TableName & string}" column "${ColumnName & string}" type is not compatible with SQLite. Column types must extend SqliteValue (string, number, Uint8Array, or null).`>
400
+ : SchemaValidationError<`Table "${TableName & string}" column "${ColumnName & string}" type is not compatible with SQLite. Column types must extend SqliteValue (string, FiniteNumber, Uint8Array, or null).`>
385
401
  : never
386
402
  : never
387
403
  : never
@@ -492,11 +508,10 @@ export const createQueryBuilder = <S extends EvoluSchema>(
492
508
  ): CreateQuery<S> => {
493
509
  const createQuery: CreateQuery<S> = (queryCallback, options) => {
494
510
  const compiledQuery = queryCallback(kysely as never).compile();
511
+ assertType(SqliteQueryParameters, compiledQuery.parameters);
495
512
  const sqliteQuery: SqliteQuery = {
496
513
  sql: compiledQuery.sql as SafeSql,
497
- parameters: compiledQuery.parameters as NonNullable<
498
- SqliteQuery["parameters"]
499
- >,
514
+ parameters: compiledQuery.parameters,
500
515
  ...(options && { options }),
501
516
  };
502
517