@livestore/common 0.0.0-snapshot-abe9ae4963ab9d3948906a6642c39bc33295e9f6 → 0.0.0-snapshot-484c9684bac8056d764aa460fd025c45f5856aa5

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 (72) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +5 -4
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/adapter-types.js.map +1 -1
  5. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  6. package/dist/devtools/devtools-messages-common.d.ts +6 -6
  7. package/dist/devtools/devtools-messages-leader.d.ts +28 -28
  8. package/dist/devtools/index.d.ts +17 -0
  9. package/dist/devtools/index.d.ts.map +1 -1
  10. package/dist/devtools/index.js +20 -0
  11. package/dist/devtools/index.js.map +1 -1
  12. package/dist/leader-thread/LeaderSyncProcessor.d.ts +3 -1
  13. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  14. package/dist/leader-thread/LeaderSyncProcessor.js +124 -43
  15. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  16. package/dist/leader-thread/apply-mutation.d.ts.map +1 -1
  17. package/dist/leader-thread/apply-mutation.js +8 -2
  18. package/dist/leader-thread/apply-mutation.js.map +1 -1
  19. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  20. package/dist/leader-thread/make-leader-thread-layer.js +1 -0
  21. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  22. package/dist/leader-thread/pull-queue-set.d.ts +3 -3
  23. package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
  24. package/dist/leader-thread/pull-queue-set.js +9 -0
  25. package/dist/leader-thread/pull-queue-set.js.map +1 -1
  26. package/dist/leader-thread/shutdown-channel.d.ts +2 -5
  27. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  28. package/dist/leader-thread/shutdown-channel.js +2 -4
  29. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  30. package/dist/leader-thread/types.d.ts +7 -2
  31. package/dist/leader-thread/types.d.ts.map +1 -1
  32. package/dist/schema/EventId.d.ts +8 -0
  33. package/dist/schema/EventId.d.ts.map +1 -1
  34. package/dist/schema/EventId.js +14 -0
  35. package/dist/schema/EventId.js.map +1 -1
  36. package/dist/schema/MutationEvent.js +1 -1
  37. package/dist/schema/MutationEvent.js.map +1 -1
  38. package/dist/schema/db-schema/dsl/field-defs.d.ts.map +1 -1
  39. package/dist/schema/db-schema/dsl/field-defs.js.map +1 -1
  40. package/dist/sync/ClientSessionSyncProcessor.d.ts +11 -1
  41. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  42. package/dist/sync/ClientSessionSyncProcessor.js +48 -14
  43. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  44. package/dist/sync/sync.d.ts +14 -9
  45. package/dist/sync/sync.d.ts.map +1 -1
  46. package/dist/sync/sync.js +7 -3
  47. package/dist/sync/sync.js.map +1 -1
  48. package/dist/sync/syncstate.d.ts +132 -21
  49. package/dist/sync/syncstate.d.ts.map +1 -1
  50. package/dist/sync/syncstate.js +129 -41
  51. package/dist/sync/syncstate.js.map +1 -1
  52. package/dist/sync/syncstate.test.js +19 -7
  53. package/dist/sync/syncstate.test.js.map +1 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +2 -2
  57. package/src/adapter-types.ts +5 -4
  58. package/src/devtools/index.ts +27 -0
  59. package/src/leader-thread/LeaderSyncProcessor.ts +164 -54
  60. package/src/leader-thread/apply-mutation.ts +17 -2
  61. package/src/leader-thread/make-leader-thread-layer.ts +1 -0
  62. package/src/leader-thread/pull-queue-set.ts +10 -1
  63. package/src/leader-thread/shutdown-channel.ts +2 -4
  64. package/src/leader-thread/types.ts +8 -2
  65. package/src/schema/EventId.ts +16 -0
  66. package/src/schema/MutationEvent.ts +1 -1
  67. package/src/schema/db-schema/dsl/field-defs.ts +1 -2
  68. package/src/sync/ClientSessionSyncProcessor.ts +78 -13
  69. package/src/sync/sync.ts +7 -4
  70. package/src/sync/syncstate.test.ts +32 -14
  71. package/src/sync/syncstate.ts +145 -60
  72. package/src/version.ts +1 -1
@@ -1,10 +1,11 @@
1
- import { memoizeByRef, shouldNeverHappen } from '@livestore/utils'
1
+ import { LS_DEV, memoizeByRef, shouldNeverHappen } from '@livestore/utils'
2
2
  import type { Scope } from '@livestore/utils/effect'
3
3
  import { Effect, Option, Schema } from '@livestore/utils/effect'
4
4
 
5
- import type { SqliteDb, SqliteError, UnexpectedError } from '../index.js'
5
+ import type { PreparedBindValues, SqliteDb, SqliteError, UnexpectedError } from '../index.js'
6
6
  import { getExecArgsFromMutation } from '../mutation.js'
7
7
  import {
8
+ EventId,
8
9
  type LiveStoreSchema,
9
10
  MUTATION_LOG_META_TABLE,
10
11
  type MutationEvent,
@@ -127,6 +128,20 @@ const insertIntoMutationLog = (
127
128
  const mutationDefSchemaHash =
128
129
  mutationDefSchemaHashMap.get(mutationName) ?? shouldNeverHappen(`Unknown mutation: ${mutationName}`)
129
130
 
131
+ if (LS_DEV && mutationEventEncoded.parentId.global !== EventId.ROOT.global) {
132
+ const parentMutationExists =
133
+ dbMutationLog.select<{ count: number }>(
134
+ `SELECT COUNT(*) as count FROM ${MUTATION_LOG_META_TABLE} WHERE idGlobal = ? AND idClient = ?`,
135
+ [mutationEventEncoded.parentId.global, mutationEventEncoded.parentId.client] as any as PreparedBindValues,
136
+ )[0]!.count === 1
137
+
138
+ if (parentMutationExists === false) {
139
+ shouldNeverHappen(
140
+ `Parent mutation ${mutationEventEncoded.parentId.global},${mutationEventEncoded.parentId.client} does not exist`,
141
+ )
142
+ }
143
+ }
144
+
130
145
  // TODO use prepared statements
131
146
  yield* execSql(
132
147
  dbMutationLog,
@@ -65,6 +65,7 @@ export const makeLeaderThreadLayer = ({
65
65
  dbMissing,
66
66
  dbMutationLog,
67
67
  initialBlockingSyncContext,
68
+ clientId,
68
69
  })
69
70
 
70
71
  const extraIncomingMessagesQueue = yield* Queue.unbounded<Devtools.Leader.MessageToApp>().pipe(
@@ -2,7 +2,7 @@ import { Effect, Queue } from '@livestore/utils/effect'
2
2
 
3
3
  import * as MutationEvent from '../schema/MutationEvent.js'
4
4
  import { getMutationEventsSince } from './mutationlog.js'
5
- import { type PullQueueItem, type PullQueueSet } from './types.js'
5
+ import { LeaderThreadCtx, type PullQueueItem, type PullQueueSet } from './types.js'
6
6
 
7
7
  export const makePullQueueSet = Effect.gen(function* () {
8
8
  const set = new Set<Queue.Queue<PullQueueItem>>()
@@ -46,6 +46,15 @@ export const makePullQueueSet = Effect.gen(function* () {
46
46
  return
47
47
  }
48
48
 
49
+ const { clientId } = yield* LeaderThreadCtx
50
+ if (clientId === 'client-b') {
51
+ // console.log(
52
+ // 'offer',
53
+ // item.payload._tag,
54
+ // item.payload.newEvents.map((_) => _.toJSON()),
55
+ // )
56
+ }
57
+
49
58
  for (const queue of set) {
50
59
  yield* Queue.offer(queue, item)
51
60
  }
@@ -1,11 +1,9 @@
1
1
  import type { WebChannel } from '@livestore/utils/effect'
2
2
  import { Schema } from '@livestore/utils/effect'
3
3
 
4
- import { IntentionalShutdownCause } from '../index.js'
4
+ import { IntentionalShutdownCause, UnexpectedError } from '../index.js'
5
5
 
6
- export class DedicatedWorkerDisconnectBroadcast extends Schema.TaggedStruct('DedicatedWorkerDisconnectBroadcast', {}) {}
7
-
8
- export class All extends Schema.Union(IntentionalShutdownCause, DedicatedWorkerDisconnectBroadcast) {}
6
+ export class All extends Schema.Union(IntentionalShutdownCause, UnexpectedError) {}
9
7
 
10
8
  /**
11
9
  * Used internally by an adapter to shutdown gracefully.
@@ -14,7 +14,7 @@ import { Context, Schema } from '@livestore/utils/effect'
14
14
  import type {
15
15
  BootStatus,
16
16
  Devtools,
17
- InvalidPushError,
17
+ LeaderAheadError,
18
18
  MakeSqliteDb,
19
19
  MigrationsReport,
20
20
  PersistenceInfo,
@@ -126,13 +126,19 @@ export interface LeaderSyncProcessor {
126
126
  /** `batch` needs to follow the same rules as `batch` in `SyncBackend.push` */
127
127
  batch: ReadonlyArray<MutationEvent.EncodedWithMeta>,
128
128
  options?: {
129
+ /**
130
+ * This generation number is used to automatically reject subsequent pushes
131
+ * of a previously rejected push from a client session. This might occur in
132
+ * certain concurrent scenarios.
133
+ */
134
+ // generation: number
129
135
  /**
130
136
  * If true, the effect will only finish when the local push has been processed (i.e. succeeded or was rejected).
131
137
  * @default false
132
138
  */
133
139
  waitForProcessing?: boolean
134
140
  },
135
- ) => Effect.Effect<void, InvalidPushError>
141
+ ) => Effect.Effect<void, LeaderAheadError>
136
142
 
137
143
  pushPartial: (args: {
138
144
  mutationEvent: MutationEvent.PartialAnyEncoded
@@ -33,6 +33,22 @@ export const compare = (a: EventId, b: EventId) => {
33
33
  return a.client - b.client
34
34
  }
35
35
 
36
+ /**
37
+ * Convert an event id to a string representation.
38
+ */
39
+ export const toString = (id: EventId) => `(${id.global},${id.client})`
40
+
41
+ /**
42
+ * Convert a string representation of an event id to an event id.
43
+ */
44
+ export const fromString = (str: string): EventId => {
45
+ const [global, client] = str.slice(1, -1).split(',').map(Number)
46
+ if (global === undefined || client === undefined) {
47
+ throw new Error('Invalid event id string')
48
+ }
49
+ return { global, client } as EventId
50
+ }
51
+
36
52
  export const isEqual = (a: EventId, b: EventId) => a.global === b.global && a.client === b.client
37
53
 
38
54
  export type EventIdPair = { id: EventId; parentId: EventId }
@@ -169,7 +169,7 @@ export class EncodedWithMeta extends Schema.Class<EncodedWithMeta>('MutationEven
169
169
  toJSON = (): any => {
170
170
  // Only used for logging/debugging
171
171
  // - More readable way to print the id + parentId
172
- // - not including `meta`
172
+ // - not including `meta`, `clientId`, `sessionId`
173
173
  return {
174
174
  id: `(${this.id.global},${this.id.client}) → (${this.parentId.global},${this.parentId.client})`,
175
175
  mutation: this.mutation,
@@ -53,8 +53,7 @@ export type ColDefFn<TColumnType extends FieldColumnType> = {
53
53
  const TNullable extends boolean = false,
54
54
  const TDefault extends TDecoded | SqlDefaultValue | NoDefault | (TNullable extends true ? null : never) = NoDefault,
55
55
  const TPrimaryKey extends boolean = false,
56
- >(args: {
57
- schema?: Schema.Schema<TDecoded, TEncoded>
56
+ >(args: { schema?: Schema.Schema<TDecoded, TEncoded>
58
57
  default?: TDefault
59
58
  nullable?: TNullable
60
59
  primaryKey?: TPrimaryKey
@@ -1,6 +1,6 @@
1
1
  import { LS_DEV, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
2
2
  import type { Runtime, Scope } from '@livestore/utils/effect'
3
- import { Effect, Queue, Schema, Stream, Subscribable } from '@livestore/utils/effect'
3
+ import { BucketQueue, Effect, FiberHandle, Queue, Schema, Stream, Subscribable } from '@livestore/utils/effect'
4
4
  import * as otel from '@opentelemetry/api'
5
5
 
6
6
  import type { ClientSession, UnexpectedError } from '../adapter-types.js'
@@ -26,6 +26,7 @@ export const makeClientSessionSyncProcessor = ({
26
26
  rollback,
27
27
  refreshTables,
28
28
  span,
29
+ params,
29
30
  }: {
30
31
  schema: LiveStoreSchema
31
32
  clientSession: ClientSession
@@ -40,6 +41,9 @@ export const makeClientSessionSyncProcessor = ({
40
41
  rollback: (changeset: Uint8Array) => void
41
42
  refreshTables: (tables: Set<string>) => void
42
43
  span: otel.Span
44
+ params: {
45
+ leaderPushBatchSize: number
46
+ }
43
47
  }): ClientSessionSyncProcessor => {
44
48
  const mutationEventSchema = MutationEvent.makeMutationEventSchemaMemo(schema)
45
49
 
@@ -59,6 +63,9 @@ export const makeClientSessionSyncProcessor = ({
59
63
  return mutationDef.options.clientOnly
60
64
  }
61
65
 
66
+ /** We're queuing push requests to reduce the number of messages sent to the leader by batching them */
67
+ const leaderPushQueue = BucketQueue.make<MutationEvent.EncodedWithMeta>().pipe(Effect.runSync)
68
+
62
69
  const push: ClientSessionSyncProcessor['push'] = (batch, { otelContext }) => {
63
70
  // TODO validate batch
64
71
 
@@ -84,6 +91,10 @@ export const makeClientSessionSyncProcessor = ({
84
91
  isEqualEvent: MutationEvent.isEqualEncoded,
85
92
  })
86
93
 
94
+ if (updateResult._tag === 'unexpected-error') {
95
+ return shouldNeverHappen('Unexpected error in client-session-sync-processor', updateResult.cause)
96
+ }
97
+
87
98
  span.addEvent('local-push', {
88
99
  batchSize: encodedMutationEvents.length,
89
100
  updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
@@ -108,13 +119,17 @@ export const makeClientSessionSyncProcessor = ({
108
119
  }
109
120
 
110
121
  // console.debug('pushToLeader', encodedMutationEvents.length, ...encodedMutationEvents.map((_) => _.toJSON()))
111
- clientSession.leaderThread.mutations
112
- .push(encodedMutationEvents)
113
- .pipe(Effect.tapCauseLogPretty, Effect.provide(runtime), Effect.runFork)
122
+ BucketQueue.offerAll(leaderPushQueue, encodedMutationEvents).pipe(Effect.runSync)
114
123
 
115
124
  return { writeTables }
116
125
  }
117
126
 
127
+ const debugInfo = {
128
+ rebaseCount: 0,
129
+ advanceCount: 0,
130
+ rejectCount: 0,
131
+ }
132
+
118
133
  const otelContext = otel.trace.setSpan(otel.context.active(), span)
119
134
 
120
135
  const boot: ClientSessionSyncProcessor['boot'] = Effect.gen(function* () {
@@ -133,6 +148,20 @@ export const makeClientSessionSyncProcessor = ({
133
148
  )
134
149
  }
135
150
 
151
+ const leaderPushingFiberHandle = yield* FiberHandle.make()
152
+
153
+ const backgroundLeaderPushing = Effect.gen(function* () {
154
+ const batch = yield* BucketQueue.takeBetween(leaderPushQueue, 1, params.leaderPushBatchSize)
155
+ yield* clientSession.leaderThread.mutations.push(batch).pipe(
156
+ Effect.catchTag('LeaderAheadError', () => {
157
+ debugInfo.rejectCount++
158
+ return BucketQueue.clear(leaderPushQueue)
159
+ }),
160
+ )
161
+ }).pipe(Effect.forever, Effect.interruptible, Effect.tapCauseLogPretty)
162
+
163
+ yield* FiberHandle.run(leaderPushingFiberHandle, backgroundLeaderPushing)
164
+
136
165
  yield* clientSession.leaderThread.mutations.pull.pipe(
137
166
  Stream.tap(({ payload, remaining }) =>
138
167
  Effect.gen(function* () {
@@ -148,9 +177,10 @@ export const makeClientSessionSyncProcessor = ({
148
177
  isEqualEvent: MutationEvent.isEqualEncoded,
149
178
  })
150
179
 
151
- if (updateResult._tag === 'reject') {
152
- debugger
153
- throw new Error('TODO: implement reject in client-session-sync-queue for pull')
180
+ if (updateResult._tag === 'unexpected-error') {
181
+ return yield* Effect.fail(updateResult.cause)
182
+ } else if (updateResult._tag === 'reject') {
183
+ return shouldNeverHappen('Unexpected reject in client-session-sync-processor', updateResult)
154
184
  }
155
185
 
156
186
  syncStateRef.current = updateResult.newSyncState
@@ -165,11 +195,21 @@ export const makeClientSessionSyncProcessor = ({
165
195
  res: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
166
196
  remaining,
167
197
  })
198
+
199
+ debugInfo.rebaseCount++
200
+
201
+ yield* FiberHandle.clear(leaderPushingFiberHandle)
202
+
203
+ // Reset the leader push queue since we're rebasing and will push again
204
+ yield* BucketQueue.clear(leaderPushQueue)
205
+
206
+ yield* FiberHandle.run(leaderPushingFiberHandle, backgroundLeaderPushing)
207
+
168
208
  if (LS_DEV) {
169
209
  Effect.logDebug(
170
210
  'pull:rebase: rollback',
171
211
  updateResult.eventsToRollback.length,
172
- ...updateResult.eventsToRollback.map((_) => _.toJSON()),
212
+ ...updateResult.eventsToRollback.slice(0, 10).map((_) => _.toJSON()),
173
213
  ).pipe(Effect.provide(runtime), Effect.runSync)
174
214
  }
175
215
 
@@ -181,9 +221,7 @@ export const makeClientSessionSyncProcessor = ({
181
221
  }
182
222
  }
183
223
 
184
- clientSession.leaderThread.mutations
185
- .push(updateResult.newSyncState.pending)
186
- .pipe(Effect.tapCauseLogPretty, Effect.provide(runtime), Effect.runFork)
224
+ yield* BucketQueue.offerAll(leaderPushQueue, updateResult.newSyncState.pending)
187
225
  } else {
188
226
  span.addEvent('pull:advance', {
189
227
  payloadTag: payload._tag,
@@ -192,6 +230,8 @@ export const makeClientSessionSyncProcessor = ({
192
230
  res: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
193
231
  remaining,
194
232
  })
233
+
234
+ debugInfo.advanceCount++
195
235
  }
196
236
 
197
237
  if (updateResult.newEvents.length === 0) return
@@ -208,10 +248,14 @@ export const makeClientSessionSyncProcessor = ({
208
248
  }
209
249
 
210
250
  refreshTables(writeTables)
211
- }),
251
+ }).pipe(
252
+ Effect.tapCauseLogPretty,
253
+ Effect.catchAllCause((cause) => Effect.sync(() => clientSession.shutdown(cause))),
254
+ ),
212
255
  ),
213
256
  Stream.runDrain,
214
257
  Effect.forever, // NOTE Whenever the leader changes, we need to re-start the stream
258
+ Effect.withSpan('client-session-sync-processor:pull'),
215
259
  Effect.tapCauseLogPretty,
216
260
  Effect.forkScoped,
217
261
  )
@@ -228,6 +272,21 @@ export const makeClientSessionSyncProcessor = ({
228
272
  }),
229
273
  changes: Stream.fromQueue(syncStateUpdateQueue),
230
274
  }),
275
+ debug: {
276
+ print: () =>
277
+ Effect.gen(function* () {
278
+ console.log('debugInfo', debugInfo)
279
+ console.log('syncState', syncStateRef.current)
280
+ const pushQueueSize = yield* BucketQueue.size(leaderPushQueue)
281
+ console.log('pushQueueSize', pushQueueSize)
282
+ const pushQueueItems = yield* BucketQueue.peekAll(leaderPushQueue)
283
+ console.log(
284
+ 'pushQueueItems',
285
+ pushQueueItems.map((_) => _.toJSON()),
286
+ )
287
+ }).pipe(Effect.provide(runtime), Effect.runSync),
288
+ debugInfo: () => debugInfo,
289
+ },
231
290
  } satisfies ClientSessionSyncProcessor
232
291
  }
233
292
 
@@ -239,6 +298,12 @@ export interface ClientSessionSyncProcessor {
239
298
  writeTables: Set<string>
240
299
  }
241
300
  boot: Effect.Effect<void, UnexpectedError, Scope.Scope>
242
-
243
301
  syncState: Subscribable.Subscribable<SyncState.SyncState>
302
+ debug: {
303
+ print: () => void
304
+ debugInfo: () => {
305
+ rebaseCount: number
306
+ advanceCount: number
307
+ }
308
+ }
244
309
  }
package/src/sync/sync.ts CHANGED
@@ -67,13 +67,16 @@ export class InvalidPushError extends Schema.TaggedError<InvalidPushError>()('In
67
67
  minimumExpectedId: Schema.Number,
68
68
  providedId: Schema.Number,
69
69
  }),
70
- Schema.TaggedStruct('LeaderAhead', {
71
- minimumExpectedId: EventId.EventId,
72
- providedId: EventId.EventId,
73
- }),
74
70
  ),
75
71
  }) {}
76
72
 
77
73
  export class InvalidPullError extends Schema.TaggedError<InvalidPullError>()('InvalidPullError', {
78
74
  message: Schema.String,
79
75
  }) {}
76
+
77
+ export class LeaderAheadError extends Schema.TaggedError<LeaderAheadError>()('LeaderAheadError', {
78
+ minimumExpectedId: EventId.EventId,
79
+ providedId: EventId.EventId,
80
+ /** Generation number the client session should use for subsequent pushes */
81
+ // nextGeneration: Schema.Number,
82
+ }) {}
@@ -17,7 +17,7 @@ class TestEvent extends MutationEvent.EncodedWithMeta {
17
17
  parentId: EventId.make(parentId),
18
18
  mutation: 'a',
19
19
  args: payload,
20
- meta: {},
20
+
21
21
  clientId: 'static-local-id',
22
22
  sessionId: undefined,
23
23
  })
@@ -87,7 +87,7 @@ describe('syncstate', () => {
87
87
  }
88
88
  expect(result.newSyncState.upstreamHead).toMatchObject(e_0_1_e_1_1.id)
89
89
  expect(result.newSyncState.localHead).toMatchObject(e_1_0_e_2_0.id)
90
- expectEventArraysEqual(result.newEvents, [e_0_0_e_1_0, e_0_1_e_1_1])
90
+ expectEventArraysEqual(result.newEvents, [e_0_0_e_1_0, e_0_1_e_1_1, e_1_0_e_2_0])
91
91
  expectEventArraysEqual(result.eventsToRollback, [e_0_0, e_0_1, e_1_0])
92
92
  })
93
93
 
@@ -118,7 +118,7 @@ describe('syncstate', () => {
118
118
  }
119
119
  expect(result.newSyncState.upstreamHead).toMatchObject(e_0_1_e_1_0.id)
120
120
  expect(result.newSyncState.localHead).toMatchObject(e_1_0_e_2_0.id)
121
- expectEventArraysEqual(result.newEvents, [e_0_1_e_1_0])
121
+ expectEventArraysEqual(result.newEvents, [e_0_1_e_1_0, e_1_0_e_2_0])
122
122
  expectEventArraysEqual(result.eventsToRollback, [e_0_1, e_1_0])
123
123
  })
124
124
 
@@ -148,12 +148,11 @@ describe('syncstate', () => {
148
148
  upstreamHead: EventId.ROOT,
149
149
  localHead: e_0_0.id,
150
150
  })
151
- expect(() =>
152
- run({
153
- syncState,
154
- payload: { _tag: 'upstream-rebase', rollbackUntil: e_0_0.id, newEvents: [e_1_0] },
155
- }),
156
- ).toThrow()
151
+ const result = run({
152
+ syncState,
153
+ payload: { _tag: 'upstream-rebase', rollbackUntil: e_0_0.id, newEvents: [e_1_0] },
154
+ })
155
+ expect(result).toMatchObject({ _tag: 'unexpected-error' })
157
156
  })
158
157
 
159
158
  it('should work for empty incoming', () => {
@@ -185,7 +184,8 @@ describe('syncstate', () => {
185
184
  upstreamHead: EventId.ROOT,
186
185
  localHead: e_0_0.id,
187
186
  })
188
- expect(() => run({ syncState, payload: { _tag: 'upstream-advance', newEvents: [e_0_1, e_0_0] } })).toThrow()
187
+ const result = run({ syncState, payload: { _tag: 'upstream-advance', newEvents: [e_0_1, e_0_0] } })
188
+ expect(result).toMatchObject({ _tag: 'unexpected-error' })
189
189
  })
190
190
 
191
191
  it('should throw error if newEvents are not sorted in ascending order by eventId (global)', () => {
@@ -195,7 +195,8 @@ describe('syncstate', () => {
195
195
  upstreamHead: EventId.ROOT,
196
196
  localHead: e_0_0.id,
197
197
  })
198
- expect(() => run({ syncState, payload: { _tag: 'upstream-advance', newEvents: [e_1_0, e_0_0] } })).toThrow()
198
+ const result = run({ syncState, payload: { _tag: 'upstream-advance', newEvents: [e_1_0, e_0_0] } })
199
+ expect(result).toMatchObject({ _tag: 'unexpected-error' })
199
200
  })
200
201
 
201
202
  it('should acknowledge pending event when receiving matching event', () => {
@@ -329,6 +330,17 @@ describe('syncstate', () => {
329
330
  expect(result.newSyncState.localHead).toMatchObject(e_1_0.id)
330
331
  expect(result.newEvents).toStrictEqual([e_1_0])
331
332
  })
333
+
334
+ it('should fail if incoming event is ≤ local head', () => {
335
+ const syncState = new SyncState.SyncState({
336
+ pending: [],
337
+ rollbackTail: [],
338
+ upstreamHead: e_1_0.id,
339
+ localHead: e_1_0.id,
340
+ })
341
+ const result = run({ syncState, payload: { _tag: 'upstream-advance', newEvents: [e_0_0] } })
342
+ expect(result).toMatchObject({ _tag: 'unexpected-error' })
343
+ })
332
344
  })
333
345
 
334
346
  describe('upstream-advance: rebase', () => {
@@ -513,14 +525,20 @@ const expectEventArraysEqual = (
513
525
  })
514
526
  }
515
527
 
516
- function expectAdvance(result: SyncState.UpdateResult): asserts result is SyncState.UpdateResultAdvance {
528
+ function expectAdvance(
529
+ result: typeof SyncState.UpdateResult.Type,
530
+ ): asserts result is typeof SyncState.UpdateResultAdvance.Type {
517
531
  expect(result._tag).toBe('advance')
518
532
  }
519
533
 
520
- function expectRebase(result: SyncState.UpdateResult): asserts result is SyncState.UpdateResultRebase {
534
+ function expectRebase(
535
+ result: typeof SyncState.UpdateResult.Type,
536
+ ): asserts result is typeof SyncState.UpdateResultRebase.Type {
521
537
  expect(result._tag).toBe('rebase')
522
538
  }
523
539
 
524
- function expectReject(result: SyncState.UpdateResult): asserts result is SyncState.UpdateResultReject {
540
+ function expectReject(
541
+ result: typeof SyncState.UpdateResult.Type,
542
+ ): asserts result is typeof SyncState.UpdateResultReject.Type {
525
543
  expect(result._tag).toBe('reject')
526
544
  }