@evolu/common 6.0.1-preview.2 → 6.0.1-preview.21

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 (154) hide show
  1. package/dist/src/Assert.d.ts +6 -3
  2. package/dist/src/Assert.d.ts.map +1 -1
  3. package/dist/src/Assert.js +7 -4
  4. package/dist/src/Brand.d.ts +75 -0
  5. package/dist/src/Brand.d.ts.map +1 -0
  6. package/dist/src/Brand.js +1 -0
  7. package/dist/src/Buffer.d.ts +1 -1
  8. package/dist/src/Buffer.d.ts.map +1 -1
  9. package/dist/src/Buffer.js +1 -1
  10. package/dist/src/CallbackRegistry.d.ts +53 -0
  11. package/dist/src/CallbackRegistry.d.ts.map +1 -0
  12. package/dist/src/CallbackRegistry.js +25 -0
  13. package/dist/src/Console.d.ts +31 -6
  14. package/dist/src/Console.d.ts.map +1 -1
  15. package/dist/src/Console.js +72 -9
  16. package/dist/src/Crypto.d.ts +61 -34
  17. package/dist/src/Crypto.d.ts.map +1 -1
  18. package/dist/src/Crypto.js +29 -42
  19. package/dist/src/Evolu/Db.d.ts +159 -66
  20. package/dist/src/Evolu/Db.d.ts.map +1 -1
  21. package/dist/src/Evolu/Db.js +300 -705
  22. package/dist/src/Evolu/Diff.d.ts +3 -3
  23. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  24. package/dist/src/Evolu/Diff.js +7 -5
  25. package/dist/src/Evolu/Evolu.d.ts +200 -115
  26. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  27. package/dist/src/Evolu/Evolu.js +287 -157
  28. package/dist/src/Evolu/Internal.d.ts +0 -2
  29. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  30. package/dist/src/Evolu/Internal.js +0 -2
  31. package/dist/src/Evolu/LocalAuth.d.ts +144 -0
  32. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  33. package/dist/src/Evolu/LocalAuth.js +171 -0
  34. package/dist/src/Evolu/Owner.d.ts +209 -124
  35. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  36. package/dist/src/Evolu/Owner.js +118 -104
  37. package/dist/src/Evolu/Platform.d.ts +9 -7
  38. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  39. package/dist/src/Evolu/Protocol.d.ts +256 -211
  40. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  41. package/dist/src/Evolu/Protocol.js +558 -359
  42. package/dist/src/Evolu/Public.d.ts +6 -8
  43. package/dist/src/Evolu/Public.d.ts.map +1 -1
  44. package/dist/src/Evolu/Public.js +2 -3
  45. package/dist/src/Evolu/PublicKysely.js +3 -3
  46. package/dist/src/Evolu/Query.d.ts +2 -1
  47. package/dist/src/Evolu/Query.d.ts.map +1 -1
  48. package/dist/src/Evolu/Relay.d.ts +72 -4
  49. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  50. package/dist/src/Evolu/Relay.js +124 -13
  51. package/dist/src/Evolu/Schema.d.ts +129 -73
  52. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  53. package/dist/src/Evolu/Schema.js +169 -89
  54. package/dist/src/Evolu/Storage.d.ts +159 -14
  55. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  56. package/dist/src/Evolu/Storage.js +42 -32
  57. package/dist/src/Evolu/Sync.d.ts +68 -13
  58. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  59. package/dist/src/Evolu/Sync.js +414 -20
  60. package/dist/src/Evolu/Timestamp.d.ts +35 -27
  61. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  62. package/dist/src/Evolu/Timestamp.js +27 -18
  63. package/dist/src/ManyToManyMap.d.ts +74 -10
  64. package/dist/src/ManyToManyMap.d.ts.map +1 -1
  65. package/dist/src/ManyToManyMap.js +41 -6
  66. package/dist/src/Number.d.ts +2 -1
  67. package/dist/src/Number.d.ts.map +1 -1
  68. package/dist/src/Random.d.ts +3 -2
  69. package/dist/src/Random.d.ts.map +1 -1
  70. package/dist/src/RefCountedResourceManager.d.ts +119 -0
  71. package/dist/src/RefCountedResourceManager.d.ts.map +1 -0
  72. package/dist/src/RefCountedResourceManager.js +197 -0
  73. package/dist/src/Result.d.ts +176 -51
  74. package/dist/src/Result.d.ts.map +1 -1
  75. package/dist/src/Result.js +30 -241
  76. package/dist/src/Sqlite.d.ts +21 -4
  77. package/dist/src/Sqlite.d.ts.map +1 -1
  78. package/dist/src/Sqlite.js +50 -8
  79. package/dist/src/Task.d.ts +511 -0
  80. package/dist/src/Task.d.ts.map +1 -0
  81. package/dist/src/Task.js +410 -0
  82. package/dist/src/Time.d.ts +59 -0
  83. package/dist/src/Time.d.ts.map +1 -1
  84. package/dist/src/Time.js +87 -4
  85. package/dist/src/Type.d.ts +474 -327
  86. package/dist/src/Type.d.ts.map +1 -1
  87. package/dist/src/Type.js +502 -436
  88. package/dist/src/Types.d.ts +0 -74
  89. package/dist/src/Types.d.ts.map +1 -1
  90. package/dist/src/WebSocket.d.ts +5 -2
  91. package/dist/src/WebSocket.d.ts.map +1 -1
  92. package/dist/src/WebSocket.js +12 -13
  93. package/dist/src/Worker.d.ts +39 -11
  94. package/dist/src/Worker.d.ts.map +1 -1
  95. package/dist/src/Worker.js +22 -4
  96. package/dist/src/index.d.ts +3 -3
  97. package/dist/src/index.d.ts.map +1 -1
  98. package/dist/src/index.js +3 -3
  99. package/package.json +14 -13
  100. package/src/Assert.ts +8 -7
  101. package/src/Brand.ts +75 -0
  102. package/src/Buffer.ts +1 -1
  103. package/src/CallbackRegistry.ts +84 -0
  104. package/src/Console.ts +91 -11
  105. package/src/Crypto.ts +94 -79
  106. package/src/Evolu/Db.ts +528 -1027
  107. package/src/Evolu/Diff.ts +7 -5
  108. package/src/Evolu/Evolu.ts +682 -329
  109. package/src/Evolu/Internal.ts +0 -2
  110. package/src/Evolu/LocalAuth.ts +422 -0
  111. package/src/Evolu/Owner.ts +282 -227
  112. package/src/Evolu/Platform.ts +9 -9
  113. package/src/Evolu/Protocol.ts +788 -640
  114. package/src/Evolu/Public.ts +7 -14
  115. package/src/Evolu/PublicKysely.ts +3 -3
  116. package/src/Evolu/Query.ts +2 -1
  117. package/src/Evolu/Relay.ts +238 -18
  118. package/src/Evolu/Schema.ts +391 -191
  119. package/src/Evolu/Storage.ts +275 -55
  120. package/src/Evolu/Sync.ts +708 -37
  121. package/src/Evolu/Timestamp.ts +38 -35
  122. package/src/ManyToManyMap.ts +127 -24
  123. package/src/Number.ts +2 -6
  124. package/src/Random.ts +3 -2
  125. package/src/RefCountedResourceManager.ts +368 -0
  126. package/src/Result.ts +183 -53
  127. package/src/Sqlite.ts +59 -23
  128. package/src/Task.ts +779 -0
  129. package/src/Time.ts +168 -4
  130. package/src/Type.ts +708 -660
  131. package/src/Types.ts +0 -76
  132. package/src/WebSocket.ts +23 -17
  133. package/src/Worker.ts +72 -23
  134. package/src/index.ts +3 -3
  135. package/dist/src/Callbacks.d.ts +0 -20
  136. package/dist/src/Callbacks.d.ts.map +0 -1
  137. package/dist/src/Callbacks.js +0 -18
  138. package/dist/src/Evolu/Config.d.ts +0 -69
  139. package/dist/src/Evolu/Config.d.ts.map +0 -1
  140. package/dist/src/Evolu/Config.js +0 -9
  141. package/dist/src/Evolu/Kysely.d.ts +0 -6
  142. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  143. package/dist/src/Evolu/Kysely.js +0 -21
  144. package/dist/src/NanoId.d.ts +0 -27
  145. package/dist/src/NanoId.d.ts.map +0 -1
  146. package/dist/src/NanoId.js +0 -6
  147. package/dist/src/Promise.d.ts +0 -180
  148. package/dist/src/Promise.d.ts.map +0 -1
  149. package/dist/src/Promise.js +0 -176
  150. package/src/Callbacks.ts +0 -43
  151. package/src/Evolu/Config.ts +0 -83
  152. package/src/Evolu/Kysely.ts +0 -38
  153. package/src/NanoId.ts +0 -39
  154. package/src/Promise.ts +0 -295
@@ -1,19 +1,25 @@
1
- import { Kysely, SelectQueryBuilder } from "kysely";
2
- import { pack } from "msgpackr";
1
+ import * as Kysely from "kysely";
3
2
  import { mapObject, objectToEntries, ReadonlyRecord } from "../Object.js";
4
- import { err, ok, Result } from "../Result.js";
5
- import { SqliteBoolean, SqliteQueryOptions, SqliteValue } from "../Sqlite.js";
3
+ import { ok, Result } from "../Result.js";
4
+ import {
5
+ SafeSql,
6
+ sql,
7
+ SqliteBoolean,
8
+ SqliteDep,
9
+ SqliteError,
10
+ SqliteQuery,
11
+ SqliteQueryOptions,
12
+ SqliteValue,
13
+ } from "../Sqlite.js";
6
14
  import {
7
15
  AnyType,
8
- brand,
9
- BrandType,
10
- createTypeErrorFormatter,
11
- DateIsoString,
12
- EvoluType,
13
- Id,
16
+ array,
17
+ DateIso,
18
+ IdBytes,
14
19
  InferErrors,
15
20
  InferInput,
16
21
  InferType,
22
+ maxMutationSize,
17
23
  MergeObjectTypeErrors,
18
24
  nullableToOptional,
19
25
  NullableToOptionalProps,
@@ -23,32 +29,22 @@ import {
23
29
  omit,
24
30
  optional,
25
31
  OptionalType,
26
- record,
32
+ String,
33
+ TableId,
27
34
  Type,
28
- TypeError,
29
- Unknown,
35
+ ValidMutationSize,
36
+ validMutationSize,
37
+ ValidMutationSizeError,
30
38
  } from "../Type.js";
31
39
  import { Simplify } from "../Types.js";
32
- import { DbSchema, DbTable } from "./Db.js";
33
- import { createIndexes, DbIndexesBuilder } from "./Kysely.js";
34
- import { AppOwner, ShardOwner, SharedOwner } from "./Owner.js";
35
- import {
36
- Base64Url256,
37
- BinaryId,
38
- maxProtocolMessageRangesSize,
39
- } from "./Protocol.js";
40
+ import { AppOwner, OwnerId } from "./Owner.js";
40
41
  import { Query, Row } from "./Query.js";
41
- import { BinaryTimestamp } from "./Timestamp.js";
42
+ import { CrdtMessage, DbChange } from "./Storage.js";
43
+ import { TimestampBytes } from "./Timestamp.js";
42
44
 
43
45
  /**
44
46
  * Defines the schema of an Evolu database.
45
47
  *
46
- * - Each top-level key represents a table name.
47
- * - The value for each table name is a record of column names mapped to their
48
- * respective data types, defined by {@link Type}.
49
- * - Each table must include a mandatory `id` column of type {@link Id}.
50
- * - No table may contain {@link DefaultColumns}.
51
- *
52
48
  * Table schema defines columns that are required for table rows. For not
53
49
  * required columns, use {@link nullOr}.
54
50
  *
@@ -82,15 +78,115 @@ import { BinaryTimestamp } from "./Timestamp.js";
82
78
  */
83
79
  export type EvoluSchema = ReadonlyRecord<
84
80
  string,
85
- ReadonlyRecord<string, Type<any, any, any, any, any>> & {
86
- readonly id: Type<any, any, any, any, any>;
87
- }
81
+ // TypeScript errors are cryptic so we use ValidateSchema.
82
+ ReadonlyRecord<string, Type<any, any, any, any, any, any>>
88
83
  >;
89
84
 
85
+ /**
86
+ * Validates an {@link EvoluSchema} at compile time, returning the first error
87
+ * found as a readable string literal type. This approach provides much clearer
88
+ * and more actionable TypeScript errors than the default, which are often hard
89
+ * to read.
90
+ *
91
+ * Validates the following schema requirements:
92
+ *
93
+ * 1. All tables must have an 'id' column
94
+ * 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
+ * 4. All column types must be compatible with SQLite (extend SqliteValue)
97
+ */
98
+ export type ValidateSchema<S extends EvoluSchema> =
99
+ ValidateSchemaHasId<S> extends never
100
+ ? ValidateIdColumnType<S> extends never
101
+ ? ValidateNoDefaultColumns<S> extends never
102
+ ? ValidateColumnTypes<S> extends never
103
+ ? S
104
+ : ValidateColumnTypes<S>
105
+ : ValidateNoDefaultColumns<S>
106
+ : ValidateIdColumnType<S>
107
+ : ValidateSchemaHasId<S>;
108
+
109
+ export type ValidateSchemaHasId<S extends EvoluSchema> =
110
+ keyof S extends infer TableName
111
+ ? TableName extends keyof S
112
+ ? "id" extends keyof S[TableName]
113
+ ? never
114
+ : SchemaValidationError<`Table "${TableName & string}" is missing required id column.`>
115
+ : never
116
+ : never;
117
+
118
+ export type ValidateIdColumnType<S extends EvoluSchema> =
119
+ keyof S extends infer TableName
120
+ ? TableName extends keyof S
121
+ ? "id" extends keyof S[TableName]
122
+ ? S[TableName]["id"] extends TableId<any>
123
+ ? never
124
+ : SchemaValidationError<`Table "${TableName & string}" id column must be a branded ID type (created with id("${TableName & string}")).`>
125
+ : never
126
+ : never
127
+ : never;
128
+
129
+ export type ValidateNoDefaultColumns<S extends EvoluSchema> =
130
+ keyof S extends infer TableName
131
+ ? TableName extends keyof S
132
+ ? keyof S[TableName] extends infer ColumnName
133
+ ? 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.`>
136
+ : never
137
+ : never
138
+ : never
139
+ : never
140
+ : never;
141
+
142
+ export type ValidateColumnTypes<S extends EvoluSchema> =
143
+ keyof S extends infer TableName
144
+ ? TableName extends keyof S
145
+ ? keyof S[TableName] extends infer ColumnName
146
+ ? ColumnName extends keyof S[TableName]
147
+ ? InferType<S[TableName][ColumnName]> extends SqliteValue
148
+ ? never
149
+ : SchemaValidationError<`Table "${TableName & string}" column "${ColumnName & string}" type is not compatible with SQLite. Column types must extend SqliteValue (string, number, Uint8Array, or null).`>
150
+ : never
151
+ : never
152
+ : never
153
+ : never;
154
+
155
+ /** Schema validation error that shows clear, readable messages */
156
+ export type SchemaValidationError<Message extends string> =
157
+ `❌ Schema Error: ${Message}`;
158
+
159
+ export type IndexesConfig = (
160
+ create: (indexName: string) => Kysely.CreateIndexBuilder,
161
+ ) => ReadonlyArray<Kysely.CreateIndexBuilder<any>>;
162
+
163
+ export const evoluSchemaToDbSchema = (
164
+ schema: EvoluSchema,
165
+ indexesConfig?: IndexesConfig,
166
+ ): DbSchema => {
167
+ const tables = objectToEntries(schema).map(([tableName, table]) => ({
168
+ name: tableName,
169
+ columns: objectToEntries(table)
170
+ .filter(([k]) => k !== "id")
171
+ .map(([k]) => k),
172
+ }));
173
+
174
+ const indexes = indexesConfig
175
+ ? indexesConfig(createIndex).map(
176
+ (index): DbIndex => ({
177
+ name: index.toOperationNode().name.name,
178
+ sql: index.compile().sql,
179
+ }),
180
+ )
181
+ : [];
182
+
183
+ return { tables, indexes };
184
+ };
185
+
90
186
  export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
91
187
  queryCallback: (
92
188
  db: Pick<
93
- Kysely<
189
+ Kysely.Kysely<
94
190
  {
95
191
  [Table in keyof S]: {
96
192
  readonly [Column in keyof S[Table]]: Column extends
@@ -102,9 +198,9 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
102
198
  } & DefaultColumns;
103
199
  } & {
104
200
  readonly evolu_history: {
105
- readonly timestamp: BinaryTimestamp;
201
+ readonly timestamp: TimestampBytes;
106
202
  readonly table: keyof S;
107
- readonly row: BinaryId;
203
+ readonly id: IdBytes;
108
204
  readonly column: string;
109
205
  readonly value: SqliteValue;
110
206
  };
@@ -112,127 +208,27 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
112
208
  >,
113
209
  "selectFrom" | "fn" | "with" | "withRecursive"
114
210
  >,
115
- ) => SelectQueryBuilder<any, any, R>,
211
+ ) => Kysely.SelectQueryBuilder<any, any, R>,
116
212
  options?: SqliteQueryOptions,
117
213
  ) => Query<Simplify<R>>;
118
214
 
215
+ /**
216
+ * Default columns automatically added to all tables.
217
+ *
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.
224
+ */
119
225
  export const DefaultColumns = object({
120
- createdAt: DateIsoString,
121
- updatedAt: DateIsoString,
226
+ createdAt: DateIso,
227
+ updatedAt: DateIso,
122
228
  isDeleted: nullOr(SqliteBoolean),
123
229
  });
124
230
  export type DefaultColumns = typeof DefaultColumns.Type;
125
231
 
126
- const isDefaultColumnName = (value: string): boolean =>
127
- value === "createdAt" || value === "updatedAt" || value === "isDeleted";
128
-
129
- /**
130
- * Valid {@link EvoluSchema}.
131
- *
132
- * - Table and column names must be Base64Url strings.
133
- * - Each table must include an `id` column of type {@link Id}.
134
- * - Default column names (`createdAt`, `updatedAt`, `isDeleted`) are not allowed.
135
- */
136
- export const ValidEvoluSchema = brand(
137
- "ValidEvoluSchema",
138
- record(
139
- Base64Url256,
140
- object({ id: EvoluType }, record(Base64Url256, Unknown)),
141
- ),
142
- (value) => {
143
- for (const tableName in value) {
144
- for (const columnName in value[tableName as never]) {
145
- if (isDefaultColumnName(columnName)) {
146
- return err<ValidEvoluSchemaError>({
147
- type: "ValidEvoluSchema",
148
- value,
149
- reason: {
150
- kind: "DefaultColumnError",
151
- tableName,
152
- columnName,
153
- },
154
- });
155
- }
156
- }
157
- }
158
-
159
- return ok(value);
160
- },
161
- );
162
-
163
- export type ValidEvoluSchema = typeof ValidEvoluSchema.Type;
164
-
165
- export interface ValidEvoluSchemaError extends TypeError<"ValidEvoluSchema"> {
166
- readonly reason: {
167
- kind: "DefaultColumnError";
168
- tableName: string;
169
- columnName: string;
170
- };
171
- }
172
-
173
- /**
174
- * Asserts that the given value is {@link ValidEvoluSchema}.
175
- *
176
- * Throws an error if the value is not a valid Evolu schema.
177
- */
178
- export const assertValidEvoluSchema = (value: unknown): ValidEvoluSchema => {
179
- const validEvoluSchema = ValidEvoluSchema.fromUnknown(value);
180
- if (!validEvoluSchema.ok) {
181
- const message = formatValidEvoluSchemaError(validEvoluSchema.error);
182
- throw new Error(`Invalid Evolu schema: ${message}`);
183
- }
184
- return validEvoluSchema.value;
185
- };
186
-
187
- const formatValidEvoluSchemaError = (
188
- error: typeof ValidEvoluSchema.Error | typeof ValidEvoluSchema.ParentError,
189
- ): string => {
190
- if (error.type === "Record") {
191
- if (error.reason.kind === "Key") {
192
- return `The table "${error.reason.key}" has invalid name. A table name must be Base64Url256 string (A-Z, a-z, 0-9, -, _).`;
193
- }
194
-
195
- if (
196
- error.reason.kind === "Value" &&
197
- error.reason.error.reason.kind === "Props" &&
198
- error.reason.error.reason.errors.id?.type === "EvoluType"
199
- ) {
200
- return `The table "${error.reason.key}" has invalid ID column. Check examples.`;
201
- }
202
-
203
- if (
204
- error.reason.kind === "Value" &&
205
- error.reason.error.reason.kind === "IndexKey"
206
- ) {
207
- 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, -, _).`;
208
- }
209
- }
210
-
211
- if (error.type === "ValidEvoluSchema") {
212
- return `The table "${error.reason.tableName}" uses reserved column name "${error.reason.columnName}". Reserved column names are: createdAt, updatedAt, isDeleted.`;
213
- }
214
-
215
- return JSON.stringify(error, null, 2);
216
- };
217
-
218
- export const validEvoluSchemaToDbSchema = (
219
- validEvoluSchema: ValidEvoluSchema,
220
- indexes?: DbIndexesBuilder,
221
- ): DbSchema => {
222
- const tables = objectToEntries(validEvoluSchema).map(
223
- ([tableName, table]): DbTable => ({
224
- name: tableName,
225
- columns: objectToEntries(table)
226
- .filter(([k]) => k !== "id")
227
- .map(([k]) => k as Base64Url256),
228
- }),
229
- );
230
- return {
231
- tables,
232
- indexes: createIndexes(indexes),
233
- };
234
- };
235
-
236
232
  export type MutationKind = "insert" | "update" | "upsert";
237
233
 
238
234
  export type Mutation<S extends EvoluSchema, Kind extends MutationKind> = <
@@ -257,7 +253,48 @@ export type MutationMapping<
257
253
  : UpsertableProps<P>;
258
254
 
259
255
  export interface MutationOptions {
256
+ /**
257
+ * Called after the mutation is completed and the local state is updated.
258
+ * Useful for triggering side effects (e.g., notifications, UI updates) after
259
+ * insert, update, or upsert.
260
+ */
260
261
  readonly onComplete?: () => void;
262
+
263
+ /**
264
+ * Specifies the owner ID for this mutation. If omitted, the default
265
+ * {@link AppOwner} is used.
266
+ *
267
+ * The owner must be used with `evolu.useOwner()` to enable sync. Mutations
268
+ * with unused owners are stored locally but not synced until the owner is
269
+ * used.
270
+ *
271
+ * ### Example
272
+ *
273
+ * ```ts
274
+ * // Partition your own data by project (derived from your AppOwner)
275
+ * const projectOwner = deriveShardOwner(appOwner, [
276
+ * "project",
277
+ * projectId,
278
+ * ]);
279
+ * evolu.insert(
280
+ * "task",
281
+ * { title: "Task 1" },
282
+ * { ownerId: projectOwner.id },
283
+ * );
284
+ *
285
+ * // Collaborative data (independent owner shared with others)
286
+ * const sharedOwner = createSharedOwner(sharedSecret);
287
+ * evolu.insert(
288
+ * "comment",
289
+ * { text: "Hello" },
290
+ * { ownerId: sharedOwner.id },
291
+ * );
292
+ * ```
293
+ *
294
+ * @experimental
295
+ */
296
+ readonly ownerId?: OwnerId;
297
+
261
298
  /**
262
299
  * Only validate, don't mutate.
263
300
  *
@@ -265,46 +302,12 @@ export interface MutationOptions {
265
302
  * `onlyValidate: true`.
266
303
  */
267
304
  readonly onlyValidate?: boolean;
268
-
269
- /**
270
- * The owner to use for this mutation. Can be a {@link ShardOwner} for sharding
271
- * app data or a {@link SharedOwner} for collaborative write access. If
272
- * omitted, defaults to the app's {@link AppOwner}.
273
- */
274
- readonly owner?: ShardOwner | SharedOwner;
275
305
  }
276
306
 
277
- /**
278
- * Evolu has to limit the maximum mutation size. Otherwise, sync couldn't use
279
- * the {@link maxProtocolMessageRangesSize}. The max size is 640KB in bytes,
280
- * measured via MessagePack. Evolu Protocol DbChange will be smaller thanks to
281
- * various optimizations.
282
- */
283
- export const maxMutationSize = 655360;
284
-
285
- const validMutationSize = <T extends AnyType>(type: T) =>
286
- brand("ValidMutationSize", type, (value) =>
287
- pack(value).byteLength <= maxMutationSize
288
- ? ok(value)
289
- : err<ValidMutationSizeError>({ type: "ValidMutationSize", value }),
290
- );
291
-
292
- export interface ValidMutationSizeError
293
- extends TypeError<"ValidMutationSize"> {}
294
-
295
- export const formatValidMutationSizeError =
296
- createTypeErrorFormatter<ValidMutationSizeError>(
297
- (error) =>
298
- `The mutation size exceeds the maximum limit of ${maxMutationSize} bytes. The provided mutation has a size of ${pack(error.value).byteLength} bytes.`,
299
- );
300
-
301
- export type ValidMutationSize<Props extends Record<string, AnyType>> =
302
- BrandType<
303
- ObjectType<Props>,
304
- "ValidMutationSize",
305
- ValidMutationSizeError,
306
- InferErrors<ObjectType<Props>>
307
- >;
307
+ export interface MutationChange extends DbChange {
308
+ /** Owner of the change. If undefined, the change belongs to the AppOwner. */
309
+ readonly ownerId?: OwnerId | undefined;
310
+ }
308
311
 
309
312
  /**
310
313
  * Type Factory to create insertable {@link Type}. It makes nullable Types
@@ -374,25 +377,41 @@ export type Updateable<Props extends Record<string, AnyType>> = InferInput<
374
377
  >;
375
378
 
376
379
  /**
377
- * Type Factory to create upsertable Type. It makes nullable Types optional and
378
- * ensures the {@link maxMutationSize}.
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}.
379
383
  *
380
384
  * ### Example
381
385
  *
382
386
  * ```ts
383
387
  * const UpsertableTodo = upsertable(Schema.todo);
384
388
  * type UpsertableTodo = typeof UpsertableTodo.Type;
385
- * const todo = UpsertableTodo.from({ id, title });
389
+ * const todo = UpsertableTodo.from({
390
+ * id,
391
+ * title,
392
+ * createdAt: "2023-01-01T00:00:00.000Z",
393
+ * });
386
394
  * if (!todo.ok) return; // handle errors
387
395
  * ```
388
396
  */
389
397
  export const upsertable = <Props extends Record<string, AnyType>>(
390
398
  props: Props,
391
- ): ValidMutationSize<UpsertableProps<Props>> =>
392
- validMutationSize(nullableToOptional(props));
399
+ ): ValidMutationSize<UpsertableProps<Props>> => {
400
+ const propsWithDefaults = {
401
+ ...props,
402
+ createdAt: optional(DateIso),
403
+ isDeleted: optional(SqliteBoolean),
404
+ };
405
+ return validMutationSize(nullableToOptional(propsWithDefaults));
406
+ };
393
407
 
394
408
  export type UpsertableProps<Props extends Record<string, AnyType>> =
395
- NullableToOptionalProps<Props>;
409
+ NullableToOptionalProps<
410
+ Props & {
411
+ createdAt: OptionalType<typeof DateIso>;
412
+ isDeleted: OptionalType<typeof SqliteBoolean>;
413
+ }
414
+ >;
396
415
 
397
416
  export type Upsertable<Props extends Record<string, AnyType>> = InferInput<
398
417
  ObjectType<UpsertableProps<Props>>
@@ -415,3 +434,184 @@ export type InferColumnErrors<
415
434
  MutationMapping<T, M>[Column]
416
435
  >;
417
436
  }[keyof MutationMapping<T, M>];
437
+
438
+ export const DbTable = object({
439
+ name: String,
440
+ columns: array(String),
441
+ });
442
+ export type DbTable = typeof DbTable.Type;
443
+
444
+ export const DbIndex = object({ name: String, sql: String });
445
+ export type DbIndex = typeof DbIndex.Type;
446
+
447
+ export const DbSchema = object({
448
+ tables: array(DbTable),
449
+ indexes: array(DbIndex),
450
+ });
451
+ export type DbSchema = typeof DbSchema.Type;
452
+
453
+ /** Get the current database schema by reading SQLite metadata. */
454
+ export const getDbSchema =
455
+ (deps: SqliteDep) =>
456
+ ({ allIndexes = false }: { allIndexes?: boolean } = {}): Result<
457
+ DbSchema,
458
+ SqliteError
459
+ > => {
460
+ const map = new Map<string, Array<string>>();
461
+
462
+ const tableAndColumnInfoRows = deps.sqlite.exec(sql`
463
+ select
464
+ sqlite_master.name as tableName,
465
+ table_info.name as columnName
466
+ from
467
+ sqlite_master
468
+ join pragma_table_info(sqlite_master.name) as table_info;
469
+ `);
470
+
471
+ if (!tableAndColumnInfoRows.ok) return tableAndColumnInfoRows;
472
+
473
+ tableAndColumnInfoRows.value.rows.forEach((row) => {
474
+ const { tableName, columnName } = row as unknown as {
475
+ tableName: string;
476
+ columnName: string;
477
+ };
478
+ if (!map.has(tableName)) map.set(tableName, []);
479
+ map.get(tableName)?.push(columnName);
480
+ });
481
+
482
+ const tables = Array.from(map, ([name, columns]) => ({ name, columns }));
483
+
484
+ const indexesRows = deps.sqlite.exec(
485
+ allIndexes
486
+ ? sql`
487
+ select name, sql
488
+ from sqlite_master
489
+ where type = 'index' and name not like 'sqlite_%';
490
+ `
491
+ : sql`
492
+ select name, sql
493
+ from sqlite_master
494
+ where
495
+ type = 'index'
496
+ and name not like 'sqlite_%'
497
+ and name not like 'evolu_%';
498
+ `,
499
+ );
500
+
501
+ if (!indexesRows.ok) return indexesRows;
502
+
503
+ const indexes = indexesRows.value.rows.map(
504
+ (row): DbIndex => ({
505
+ name: row.name as string,
506
+ /**
507
+ * SQLite returns "CREATE INDEX" for "create index" for some reason.
508
+ * Other keywords remain unchanged. We have to normalize the casing for
509
+ * {@link indexesAreEqual} manually.
510
+ */
511
+ sql: (row.sql as string)
512
+ .replace("CREATE INDEX", "create index")
513
+ .replace("CREATE UNIQUE INDEX", "create unique index"),
514
+ }),
515
+ );
516
+
517
+ return ok({ tables, indexes });
518
+ };
519
+
520
+ const indexesAreEqual = (self: DbIndex, that: DbIndex): boolean =>
521
+ self.name === that.name && self.sql === that.sql;
522
+
523
+ export const ensureDbSchema =
524
+ (deps: SqliteDep) =>
525
+ (
526
+ newSchema: DbSchema,
527
+ currentSchema: DbSchema,
528
+ options?: { ignoreIndexes: boolean },
529
+ ): Result<void, SqliteError> => {
530
+ const queries: Array<SqliteQuery> = [];
531
+
532
+ newSchema.tables.forEach((newTable) => {
533
+ const currentTable = currentSchema.tables.find(
534
+ (t) => t.name === newTable.name,
535
+ );
536
+ if (!currentTable) {
537
+ queries.push({
538
+ sql: createTableWithDefaultColumns(newTable.name, newTable.columns),
539
+ parameters: [],
540
+ });
541
+ } else {
542
+ newTable.columns
543
+ .filter((newColumn) => !currentTable.columns.includes(newColumn))
544
+ .forEach((newColumn) => {
545
+ queries.push(sql`
546
+ alter table ${sql.identifier(newTable.name)}
547
+ add column ${sql.identifier(newColumn)} blob;
548
+ `);
549
+ });
550
+ }
551
+ });
552
+
553
+ if (options?.ignoreIndexes !== true) {
554
+ // Remove current indexes that are not in the newSchema.
555
+ currentSchema.indexes
556
+ .filter(
557
+ (currentIndex) =>
558
+ !newSchema.indexes.some((newIndex) =>
559
+ indexesAreEqual(newIndex, currentIndex),
560
+ ),
561
+ )
562
+ .forEach((index) => {
563
+ queries.push(sql`drop index ${sql.identifier(index.name)};`);
564
+ });
565
+
566
+ // Add new indexes that are not in the currentSchema.
567
+ newSchema.indexes
568
+ .filter(
569
+ (newIndex) =>
570
+ !currentSchema.indexes.some((currentIndex) =>
571
+ indexesAreEqual(newIndex, currentIndex),
572
+ ),
573
+ )
574
+ .forEach((newIndex) => {
575
+ queries.push({ sql: `${newIndex.sql};` as SafeSql, parameters: [] });
576
+ });
577
+ }
578
+
579
+ for (const query of queries) {
580
+ const result = deps.sqlite.exec(query);
581
+ if (!result.ok) return result;
582
+ }
583
+ return ok();
584
+ };
585
+
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"])
596
+ .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;
604
+
605
+ // https://kysely.dev/docs/recipes/splitting-query-building-and-execution
606
+ export const kysely = new Kysely.Kysely({
607
+ dialect: {
608
+ createAdapter: () => new Kysely.SqliteAdapter(),
609
+ createDriver: () => new Kysely.DummyDriver(),
610
+ createIntrospector() {
611
+ throw new Error("Not implemeneted");
612
+ },
613
+ createQueryCompiler: () => new Kysely.SqliteQueryCompiler(),
614
+ },
615
+ });
616
+
617
+ const createIndex = kysely.schema.createIndex.bind(kysely.schema);