@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.
- package/README.md +2 -2
- package/dist/src/Array.d.ts +3 -3
- package/dist/src/Array.js +3 -3
- package/dist/src/Crypto.d.ts +6 -8
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +9 -9
- package/dist/src/Evolu/Db.d.ts.map +1 -1
- package/dist/src/Evolu/Db.js +4 -4
- package/dist/src/Evolu/Evolu.d.ts +9 -4
- package/dist/src/Evolu/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu/Evolu.js +14 -14
- package/dist/src/Evolu/Protocol.d.ts +27 -2
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +71 -31
- package/dist/src/Evolu/PublicKysely.d.ts.map +1 -1
- package/dist/src/Evolu/PublicKysely.js +0 -1
- package/dist/src/Evolu/Query.d.ts +1 -1
- package/dist/src/Evolu/Query.d.ts.map +1 -1
- package/dist/src/Evolu/Query.js +1 -1
- package/dist/src/Evolu/Schema.d.ts +28 -22
- package/dist/src/Evolu/Schema.d.ts.map +1 -1
- package/dist/src/Evolu/Schema.js +37 -32
- package/dist/src/Evolu/Storage.d.ts +11 -2
- package/dist/src/Evolu/Storage.d.ts.map +1 -1
- package/dist/src/Evolu/Storage.js +21 -3
- package/dist/src/Evolu/Sync.d.ts +4 -3
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +68 -40
- package/dist/src/Evolu/Timestamp.d.ts +10 -14
- package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
- package/dist/src/Evolu/Timestamp.js +3 -16
- package/dist/src/Object.d.ts +10 -4
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +9 -3
- package/dist/src/Sqlite.d.ts +29 -3
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +29 -3
- package/dist/src/Type.d.ts +94 -47
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +110 -65
- package/package.json +2 -2
- package/src/Array.ts +3 -3
- package/src/Crypto.ts +11 -9
- package/src/Evolu/Db.ts +7 -7
- package/src/Evolu/Evolu.ts +31 -24
- package/src/Evolu/Protocol.ts +84 -37
- package/src/Evolu/PublicKysely.ts +1 -2
- package/src/Evolu/Query.ts +2 -2
- package/src/Evolu/Schema.ts +54 -50
- package/src/Evolu/Storage.ts +39 -2
- package/src/Evolu/Sync.ts +97 -43
- package/src/Evolu/Timestamp.ts +5 -25
- package/src/Object.ts +13 -5
- package/src/Sqlite.ts +33 -3
- package/src/Type.ts +124 -80
package/src/Evolu/Schema.ts
CHANGED
|
@@ -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
|
|
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
|
-
?
|
|
102
|
+
? ValidateNoSystemColumns<S> extends never
|
|
102
103
|
? ValidateColumnTypes<S> extends never
|
|
103
104
|
? S
|
|
104
105
|
: ValidateColumnTypes<S>
|
|
105
|
-
:
|
|
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
|
|
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
|
|
135
|
-
|
|
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
|
-
} &
|
|
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
|
-
*
|
|
218
|
+
* System columns that are implicitly defined by Evolu.
|
|
217
219
|
*
|
|
218
|
-
* - `createdAt`: Set by Evolu
|
|
219
|
-
*
|
|
220
|
-
* - `
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
382
|
-
*
|
|
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
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
.map((name) => `${sql.identifier(name).sql}
|
|
601
|
-
.join(", ")}
|
|
602
|
-
)
|
|
603
|
-
|
|
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({
|
package/src/Evolu/Storage.ts
CHANGED
|
@@ -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:
|
|
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 {
|
|
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 {
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
635
|
-
where 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
|
|
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
|
|
672
|
+
for (const [column, value] of entries) {
|
|
642
673
|
const result = deps.sqlite.exec(sql.prepared`
|
|
643
|
-
insert into ${sql.identifier(
|
|
644
|
-
("id", ${sql.identifier(column)}, createdAt, updatedAt)
|
|
645
|
-
values (${
|
|
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 = ${
|
|
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
|
|
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
|
-
(
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
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" = ${
|
|
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}, ${
|
|
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 = ${
|
|
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
|
-
|
|
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
|
*
|
package/src/Evolu/Timestamp.ts
CHANGED
|
@@ -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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
):
|
|
35
|
-
Object.entries(record) as Array<
|
|
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
|