@livestore/common 0.0.0-snapshot-f6ec49b1a18859aad769f0a0d8edf8bae231ed07 → 0.0.0-snapshot-2ef046b02334f52613d31dbe06af53487685edc0

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 (102) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +7 -12
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/adapter-types.js +1 -7
  5. package/dist/adapter-types.js.map +1 -1
  6. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  7. package/dist/devtools/devtools-messages-common.d.ts +13 -6
  8. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  9. package/dist/devtools/devtools-messages-common.js +6 -0
  10. package/dist/devtools/devtools-messages-common.js.map +1 -1
  11. package/dist/devtools/devtools-messages-leader.d.ts +25 -25
  12. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  13. package/dist/devtools/devtools-messages-leader.js +1 -2
  14. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  15. package/dist/leader-thread/LeaderSyncProcessor.d.ts +16 -6
  16. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  17. package/dist/leader-thread/LeaderSyncProcessor.js +227 -215
  18. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  19. package/dist/leader-thread/apply-mutation.d.ts +14 -9
  20. package/dist/leader-thread/apply-mutation.d.ts.map +1 -1
  21. package/dist/leader-thread/apply-mutation.js +43 -36
  22. package/dist/leader-thread/apply-mutation.js.map +1 -1
  23. package/dist/leader-thread/leader-worker-devtools.d.ts.map +1 -1
  24. package/dist/leader-thread/leader-worker-devtools.js +2 -5
  25. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  26. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  27. package/dist/leader-thread/make-leader-thread-layer.js +22 -33
  28. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  29. package/dist/leader-thread/mod.d.ts +1 -1
  30. package/dist/leader-thread/mod.d.ts.map +1 -1
  31. package/dist/leader-thread/mod.js +1 -1
  32. package/dist/leader-thread/mod.js.map +1 -1
  33. package/dist/leader-thread/mutationlog.d.ts +20 -3
  34. package/dist/leader-thread/mutationlog.d.ts.map +1 -1
  35. package/dist/leader-thread/mutationlog.js +106 -12
  36. package/dist/leader-thread/mutationlog.js.map +1 -1
  37. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  38. package/dist/leader-thread/recreate-db.js +4 -3
  39. package/dist/leader-thread/recreate-db.js.map +1 -1
  40. package/dist/leader-thread/types.d.ts +35 -19
  41. package/dist/leader-thread/types.d.ts.map +1 -1
  42. package/dist/leader-thread/types.js.map +1 -1
  43. package/dist/rehydrate-from-mutationlog.d.ts +5 -4
  44. package/dist/rehydrate-from-mutationlog.d.ts.map +1 -1
  45. package/dist/rehydrate-from-mutationlog.js +7 -9
  46. package/dist/rehydrate-from-mutationlog.js.map +1 -1
  47. package/dist/schema/EventId.d.ts +4 -0
  48. package/dist/schema/EventId.d.ts.map +1 -1
  49. package/dist/schema/EventId.js +7 -1
  50. package/dist/schema/EventId.js.map +1 -1
  51. package/dist/schema/MutationEvent.d.ts +87 -18
  52. package/dist/schema/MutationEvent.d.ts.map +1 -1
  53. package/dist/schema/MutationEvent.js +35 -6
  54. package/dist/schema/MutationEvent.js.map +1 -1
  55. package/dist/schema/schema.js +1 -1
  56. package/dist/schema/schema.js.map +1 -1
  57. package/dist/schema/system-tables.d.ts +67 -0
  58. package/dist/schema/system-tables.d.ts.map +1 -1
  59. package/dist/schema/system-tables.js +12 -1
  60. package/dist/schema/system-tables.js.map +1 -1
  61. package/dist/sync/ClientSessionSyncProcessor.d.ts +11 -1
  62. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  63. package/dist/sync/ClientSessionSyncProcessor.js +54 -47
  64. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  65. package/dist/sync/sync.d.ts +16 -5
  66. package/dist/sync/sync.d.ts.map +1 -1
  67. package/dist/sync/sync.js.map +1 -1
  68. package/dist/sync/syncstate.d.ts +81 -83
  69. package/dist/sync/syncstate.d.ts.map +1 -1
  70. package/dist/sync/syncstate.js +159 -125
  71. package/dist/sync/syncstate.js.map +1 -1
  72. package/dist/sync/syncstate.test.js +97 -138
  73. package/dist/sync/syncstate.test.js.map +1 -1
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/package.json +2 -2
  77. package/src/adapter-types.ts +5 -12
  78. package/src/devtools/devtools-messages-common.ts +9 -0
  79. package/src/devtools/devtools-messages-leader.ts +1 -2
  80. package/src/leader-thread/LeaderSyncProcessor.ts +398 -370
  81. package/src/leader-thread/apply-mutation.ts +81 -71
  82. package/src/leader-thread/leader-worker-devtools.ts +3 -8
  83. package/src/leader-thread/make-leader-thread-layer.ts +27 -41
  84. package/src/leader-thread/mod.ts +1 -1
  85. package/src/leader-thread/mutationlog.ts +167 -13
  86. package/src/leader-thread/recreate-db.ts +4 -3
  87. package/src/leader-thread/types.ts +34 -23
  88. package/src/rehydrate-from-mutationlog.ts +12 -12
  89. package/src/schema/EventId.ts +8 -1
  90. package/src/schema/MutationEvent.ts +42 -10
  91. package/src/schema/schema.ts +1 -1
  92. package/src/schema/system-tables.ts +20 -1
  93. package/src/sync/ClientSessionSyncProcessor.ts +64 -50
  94. package/src/sync/sync.ts +16 -9
  95. package/src/sync/syncstate.test.ts +173 -217
  96. package/src/sync/syncstate.ts +184 -151
  97. package/src/version.ts +1 -1
  98. package/dist/leader-thread/pull-queue-set.d.ts +0 -7
  99. package/dist/leader-thread/pull-queue-set.d.ts.map +0 -1
  100. package/dist/leader-thread/pull-queue-set.js +0 -48
  101. package/dist/leader-thread/pull-queue-set.js.map +0 -1
  102. package/src/leader-thread/pull-queue-set.ts +0 -67
@@ -5,12 +5,14 @@ import type {
5
5
  Option,
6
6
  Queue,
7
7
  Scope,
8
+ Stream,
8
9
  Subscribable,
9
10
  SubscriptionRef,
10
11
  WebChannel,
11
12
  } from '@livestore/utils/effect'
12
13
  import { Context, Schema } from '@livestore/utils/effect'
13
14
 
15
+ import type { SqliteError } from '../adapter-types.js'
14
16
  import type {
15
17
  BootStatus,
16
18
  Devtools,
@@ -97,7 +99,7 @@ export class LeaderThreadCtx extends Context.Tag('LeaderThreadCtx')<
97
99
  devtools: DevtoolsContext
98
100
  syncBackend: SyncBackend | undefined
99
101
  syncProcessor: LeaderSyncProcessor
100
- connectedClientSessionPullQueues: PullQueueSet
102
+ applyMutation: ApplyMutation
101
103
  initialState: {
102
104
  leaderHead: EventId.EventId
103
105
  migrationsReport: MigrationsReport
@@ -111,27 +113,42 @@ export class LeaderThreadCtx extends Context.Tag('LeaderThreadCtx')<
111
113
  }
112
114
  >() {}
113
115
 
116
+ export type ApplyMutation = (
117
+ mutationEventEncoded: MutationEvent.EncodedWithMeta,
118
+ options?: {
119
+ /** Needed for rehydrateFromMutationLog */
120
+ skipMutationLog?: boolean
121
+ },
122
+ ) => Effect.Effect<
123
+ { sessionChangeset: { _tag: 'sessionChangeset'; data: Uint8Array; debug: any } | { _tag: 'no-op' } },
124
+ SqliteError | UnexpectedError
125
+ >
126
+
114
127
  export type InitialBlockingSyncContext = {
115
128
  blockingDeferred: Deferred.Deferred<void> | undefined
116
129
  update: (_: { remaining: number; processed: number }) => Effect.Effect<void>
117
130
  }
118
131
 
119
- export type PullQueueItem = {
120
- payload: SyncState.PayloadUpstream
121
- remaining: number
122
- }
123
-
124
132
  export interface LeaderSyncProcessor {
133
+ /** Used by client sessions to subscribe to upstream sync state changes */
134
+ pull: (args: {
135
+ /** Leader merge counter */
136
+ cursor: number
137
+ }) => Stream.Stream<{ payload: typeof SyncState.PayloadUpstream.Type; mergeCounter: number }, UnexpectedError>
138
+ /** The `pullQueue` API can be used instead of `pull` when more convenient */
139
+ pullQueue: (args: {
140
+ cursor: number
141
+ }) => Effect.Effect<
142
+ Queue.Queue<{ payload: typeof SyncState.PayloadUpstream.Type; mergeCounter: number }>,
143
+ UnexpectedError,
144
+ Scope.Scope
145
+ >
146
+
147
+ /** Used by client sessions to push mutations to the leader thread */
125
148
  push: (
126
149
  /** `batch` needs to follow the same rules as `batch` in `SyncBackend.push` */
127
150
  batch: ReadonlyArray<MutationEvent.EncodedWithMeta>,
128
151
  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
135
152
  /**
136
153
  * If true, the effect will only finish when the local push has been processed (i.e. succeeded or was rejected).
137
154
  * @default false
@@ -140,24 +157,18 @@ export interface LeaderSyncProcessor {
140
157
  },
141
158
  ) => Effect.Effect<void, LeaderAheadError>
142
159
 
160
+ /** Currently only used by devtools which don't provide their own event numbers */
143
161
  pushPartial: (args: {
144
162
  mutationEvent: MutationEvent.PartialAnyEncoded
145
163
  clientId: string
146
164
  sessionId: string
147
- }) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx>
148
- boot: (args: {
149
- dbReady: Deferred.Deferred<void>
150
- }) => Effect.Effect<
165
+ }) => Effect.Effect<void, UnexpectedError>
166
+
167
+ boot: Effect.Effect<
151
168
  { initialLeaderHead: EventId.EventId },
152
169
  UnexpectedError,
153
170
  LeaderThreadCtx | Scope.Scope | HttpClient.HttpClient
154
171
  >
155
172
  syncState: Subscribable.Subscribable<SyncState.SyncState>
156
- }
157
-
158
- export interface PullQueueSet {
159
- makeQueue: (
160
- since: EventId.EventId,
161
- ) => Effect.Effect<Queue.Queue<PullQueueItem>, UnexpectedError, Scope.Scope | LeaderThreadCtx>
162
- offer: (item: PullQueueItem) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx>
173
+ getMergeCounter: () => number
163
174
  }
@@ -2,35 +2,35 @@ import { memoizeByRef } from '@livestore/utils'
2
2
  import { Chunk, Effect, Option, Schema, Stream } from '@livestore/utils/effect'
3
3
 
4
4
  import { type MigrationOptionsFromMutationLog, type SqliteDb, UnexpectedError } from './adapter-types.js'
5
- import { makeApplyMutation } from './leader-thread/apply-mutation.js'
6
- import type { LiveStoreSchema, MutationDef, MutationEvent, MutationLogMetaRow } from './schema/mod.js'
7
- import { EventId, getMutationDef, MUTATION_LOG_META_TABLE } from './schema/mod.js'
5
+ import type { ApplyMutation } from './leader-thread/mod.js'
6
+ import type { LiveStoreSchema, MutationDef, MutationLogMetaRow } from './schema/mod.js'
7
+ import { EventId, getMutationDef, MUTATION_LOG_META_TABLE, MutationEvent } from './schema/mod.js'
8
8
  import type { PreparedBindValues } from './util.js'
9
9
  import { sql } from './util.js'
10
10
 
11
11
  export const rehydrateFromMutationLog = ({
12
- logDb,
12
+ dbMutationLog,
13
13
  // TODO re-use this db when bringing back the boot in-memory db implementation
14
14
  // db,
15
15
  schema,
16
16
  migrationOptions,
17
17
  onProgress,
18
+ applyMutation,
18
19
  }: {
19
- logDb: SqliteDb
20
- db: SqliteDb
20
+ dbMutationLog: SqliteDb
21
+ // db: SqliteDb
21
22
  schema: LiveStoreSchema
22
23
  migrationOptions: MigrationOptionsFromMutationLog
23
24
  onProgress: (_: { done: number; total: number }) => Effect.Effect<void>
25
+ applyMutation: ApplyMutation
24
26
  }) =>
25
27
  Effect.gen(function* () {
26
- const mutationsCount = logDb.select<{ count: number }>(
28
+ const mutationsCount = dbMutationLog.select<{ count: number }>(
27
29
  `SELECT COUNT(*) AS count FROM ${MUTATION_LOG_META_TABLE}`,
28
30
  )[0]!.count
29
31
 
30
32
  const hashMutation = memoizeByRef((mutation: MutationDef.Any) => Schema.hash(mutation.schema))
31
33
 
32
- const applyMutation = yield* makeApplyMutation
33
-
34
34
  const processMutation = (row: MutationLogMetaRow) =>
35
35
  Effect.gen(function* () {
36
36
  const mutationDef = getMutationDef(schema, row.mutation)
@@ -59,21 +59,21 @@ This likely means the schema has changed in an incompatible way.
59
59
  ),
60
60
  )
61
61
 
62
- const mutationEventEncoded = {
62
+ const mutationEventEncoded = MutationEvent.EncodedWithMeta.make({
63
63
  id: { global: row.idGlobal, client: row.idClient },
64
64
  parentId: { global: row.parentIdGlobal, client: row.parentIdClient },
65
65
  mutation: row.mutation,
66
66
  args,
67
67
  clientId: row.clientId,
68
68
  sessionId: row.sessionId,
69
- } satisfies MutationEvent.AnyEncoded
69
+ })
70
70
 
71
71
  yield* applyMutation(mutationEventEncoded, { skipMutationLog: true })
72
72
  }).pipe(Effect.withSpan(`@livestore/common:rehydrateFromMutationLog:processMutation`))
73
73
 
74
74
  const CHUNK_SIZE = 100
75
75
 
76
- const stmt = logDb.prepare(sql`\
76
+ const stmt = dbMutationLog.prepare(sql`\
77
77
  SELECT * FROM ${MUTATION_LOG_META_TABLE}
78
78
  WHERE idGlobal > $idGlobal OR (idGlobal = $idGlobal AND idClient > $idClient)
79
79
  ORDER BY idGlobal ASC, idClient ASC
@@ -18,8 +18,15 @@ export const clientDefault = 0 as any as ClientEventId
18
18
  */
19
19
  export type EventId = { global: GlobalEventId; client: ClientEventId }
20
20
 
21
+ // export const EventSequenceNumber = Schema.Struct({})
22
+ // export const EventNumber = Schema.Struct({})
23
+
24
+ /**
25
+ * NOTE: Client mutation events with a non-0 client id, won't be synced to the sync backend.
26
+ */
21
27
  export const EventId = Schema.Struct({
22
28
  global: GlobalEventId,
29
+ /** Only increments for clientOnly mutations */
23
30
  client: ClientEventId,
24
31
  }).annotations({ title: 'LiveStore.EventId' })
25
32
 
@@ -36,7 +43,7 @@ export const compare = (a: EventId, b: EventId) => {
36
43
  /**
37
44
  * Convert an event id to a string representation.
38
45
  */
39
- export const toString = (id: EventId) => `(${id.global},${id.client})`
46
+ export const toString = (id: EventId) => (id.client === 0 ? `e${id.global}` : `e${id.global}+${id.client}`)
40
47
 
41
48
  /**
42
49
  * Convert a string representation of an event id to an event id.
@@ -1,5 +1,5 @@
1
1
  import { memoizeByRef } from '@livestore/utils'
2
- import { Schema } from '@livestore/utils/effect'
2
+ import { Option, Schema } from '@livestore/utils/effect'
3
3
 
4
4
  import * as EventId from './EventId.js'
5
5
  import type { MutationDef, MutationDefRecord } from './mutations.js'
@@ -155,16 +155,29 @@ export const makeMutationEventSchemaMemo = memoizeByRef(makeMutationEventSchema)
155
155
  export class EncodedWithMeta extends Schema.Class<EncodedWithMeta>('MutationEvent.EncodedWithMeta')({
156
156
  mutation: Schema.String,
157
157
  args: Schema.Any,
158
+ // TODO rename to `.num` / `.parentNum`
158
159
  id: EventId.EventId,
159
160
  parentId: EventId.EventId,
160
161
  clientId: Schema.String,
161
162
  sessionId: Schema.String,
162
163
  // TODO get rid of `meta` again by cleaning up the usage implementations
163
- meta: Schema.optionalWith(
164
- Schema.Any as Schema.Schema<{
165
- sessionChangeset?: Uint8Array
166
- }>,
167
- { default: () => ({}) },
164
+ meta: Schema.Struct({
165
+ sessionChangeset: Schema.Union(
166
+ Schema.TaggedStruct('sessionChangeset', {
167
+ data: Schema.Uint8Array,
168
+ debug: Schema.Any.pipe(Schema.optional),
169
+ }),
170
+ Schema.TaggedStruct('no-op', {}),
171
+ Schema.TaggedStruct('unset', {}),
172
+ ),
173
+ syncMetadata: Schema.Option(Schema.JsonValue),
174
+ }).pipe(
175
+ Schema.mutable,
176
+ Schema.optional,
177
+ Schema.withDefaults({
178
+ constructor: () => ({ sessionChangeset: { _tag: 'unset' as const }, syncMetadata: Option.none() }),
179
+ decoding: () => ({ sessionChangeset: { _tag: 'unset' as const }, syncMetadata: Option.none() }),
180
+ }),
168
181
  ),
169
182
  }) {
170
183
  toJSON = (): any => {
@@ -172,23 +185,41 @@ export class EncodedWithMeta extends Schema.Class<EncodedWithMeta>('MutationEven
172
185
  // - More readable way to print the id + parentId
173
186
  // - not including `meta`, `clientId`, `sessionId`
174
187
  return {
175
- id: `(${this.id.global},${this.id.client})(${this.parentId.global},${this.parentId.client})`,
188
+ id: `${EventId.toString(this.id)} → ${EventId.toString(this.parentId)}`,
176
189
  mutation: this.mutation,
177
190
  args: this.args,
178
191
  }
179
192
  }
180
193
 
181
- rebase = (parentId: EventId.EventId, isLocal: boolean) =>
194
+ /**
195
+ * Example: (global event)
196
+ * For event id e2 → e1 which should be rebased on event id e3 → e2
197
+ * the resulting event id will be e4 → e3
198
+ *
199
+ * Example: (client event)
200
+ * For event id e2+1 → e2 which should be rebased on event id e3 → e2
201
+ * the resulting event id will be e3+1 → e3
202
+ *
203
+ * Syntax: e2+2 → e2+1
204
+ * ^ ^ ^ ^
205
+ * | | | +- client parent id
206
+ * | | +--- global parent id
207
+ * | +-- client id
208
+ * +---- global id
209
+ * Client id is ommitted for global events
210
+ */
211
+ rebase = (parentId: EventId.EventId, isClient: boolean) =>
182
212
  new EncodedWithMeta({
183
213
  ...this,
184
- ...EventId.nextPair(parentId, isLocal),
214
+ ...EventId.nextPair(parentId, isClient),
185
215
  })
186
216
 
187
- static fromGlobal = (mutationEvent: AnyEncodedGlobal) =>
217
+ static fromGlobal = (mutationEvent: AnyEncodedGlobal, syncMetadata: Option.Option<Schema.JsonValue>) =>
188
218
  new EncodedWithMeta({
189
219
  ...mutationEvent,
190
220
  id: { global: mutationEvent.id, client: EventId.clientDefault },
191
221
  parentId: { global: mutationEvent.parentId, client: EventId.clientDefault },
222
+ meta: { sessionChangeset: { _tag: 'unset' as const }, syncMetadata },
192
223
  })
193
224
 
194
225
  toGlobal = (): AnyEncodedGlobal => ({
@@ -198,6 +229,7 @@ export class EncodedWithMeta extends Schema.Class<EncodedWithMeta>('MutationEven
198
229
  })
199
230
  }
200
231
 
232
+ /** NOTE `meta` is not considered for equality */
201
233
  export const isEqualEncoded = (a: AnyEncoded, b: AnyEncoded) =>
202
234
  a.id.global === b.id.global &&
203
235
  a.id.client === b.id.client &&
@@ -112,7 +112,7 @@ export const makeSchema = <TInputSchema extends InputSchema>(
112
112
  _MutationDefMapType: Symbol.for('livestore.MutationDefMapType') as any,
113
113
  tables,
114
114
  mutations,
115
- migrationOptions: inputSchema.migrations ?? { strategy: 'hard-reset' },
115
+ migrationOptions: inputSchema.migrations ?? { strategy: 'from-mutation-log' },
116
116
  hash,
117
117
  } satisfies LiveStoreSchema
118
118
  }
@@ -60,7 +60,25 @@ export const sessionChangesetMetaTable = table(
60
60
 
61
61
  export type SessionChangesetMetaRow = FromTable.RowDecoded<typeof sessionChangesetMetaTable>
62
62
 
63
- export const systemTables = [schemaMetaTable, schemaMutationsMetaTable, sessionChangesetMetaTable]
63
+ export const LEADER_MERGE_COUNTER_TABLE = '__livestore_leader_merge_counter'
64
+
65
+ export const leaderMergeCounterTable = table(
66
+ LEADER_MERGE_COUNTER_TABLE,
67
+ {
68
+ id: SqliteDsl.integer({ primaryKey: true, schema: Schema.Literal(0) }),
69
+ mergeCounter: SqliteDsl.integer({ primaryKey: true }),
70
+ },
71
+ { disableAutomaticIdColumn: true },
72
+ )
73
+
74
+ export type LeaderMergeCounterRow = FromTable.RowDecoded<typeof leaderMergeCounterTable>
75
+
76
+ export const systemTables = [
77
+ schemaMetaTable,
78
+ schemaMutationsMetaTable,
79
+ sessionChangesetMetaTable,
80
+ leaderMergeCounterTable,
81
+ ]
64
82
 
65
83
  /// Mutation log DB
66
84
 
@@ -72,6 +90,7 @@ export const MUTATION_LOG_META_TABLE = 'mutation_log'
72
90
  export const mutationLogMetaTable = table(
73
91
  MUTATION_LOG_META_TABLE,
74
92
  {
93
+ // TODO Adjust modeling so a global event never needs a client id component
75
94
  idGlobal: SqliteDsl.integer({ primaryKey: true, schema: EventId.GlobalEventId }),
76
95
  idClient: SqliteDsl.integer({ primaryKey: true, schema: EventId.ClientEventId }),
77
96
  parentIdGlobal: SqliteDsl.integer({ schema: EventId.GlobalEventId }),
@@ -5,8 +5,9 @@ import * as otel from '@opentelemetry/api'
5
5
 
6
6
  import type { ClientSession, UnexpectedError } from '../adapter-types.js'
7
7
  import * as EventId from '../schema/EventId.js'
8
- import { getMutationDef, type LiveStoreSchema } from '../schema/mod.js'
8
+ import { getMutationDef, LEADER_MERGE_COUNTER_TABLE, type LiveStoreSchema } from '../schema/mod.js'
9
9
  import * as MutationEvent from '../schema/MutationEvent.js'
10
+ import { sql } from '../util.js'
10
11
  import * as SyncState from './syncstate.js'
11
12
 
12
13
  /**
@@ -17,6 +18,8 @@ import * as SyncState from './syncstate.js'
17
18
  * - The goal is to never block the UI, so we'll interrupt rebasing if a new mutations is pushed by the client session.
18
19
  * - We also want to avoid "backwards-jumping" in the UI, so we'll transactionally apply a read model changes during a rebase.
19
20
  * - We might need to make the rebase behaviour configurable e.g. to let users manually trigger a rebase
21
+ *
22
+ * Longer term we should evalutate whether we can unify the ClientSessionSyncProcessor with the LeaderSyncProcessor.
20
23
  */
21
24
  export const makeClientSessionSyncProcessor = ({
22
25
  schema,
@@ -37,7 +40,7 @@ export const makeClientSessionSyncProcessor = ({
37
40
  options: { otelContext: otel.Context; withChangeset: boolean },
38
41
  ) => {
39
42
  writeTables: Set<string>
40
- sessionChangeset: Uint8Array | undefined
43
+ sessionChangeset: { _tag: 'sessionChangeset'; data: Uint8Array; debug: any } | { _tag: 'no-op' } | { _tag: 'unset' }
41
44
  }
42
45
  rollback: (changeset: Uint8Array) => void
43
46
  refreshTables: (tables: Set<string>) => void
@@ -54,17 +57,17 @@ export const makeClientSessionSyncProcessor = ({
54
57
  const mutationEventSchema = MutationEvent.makeMutationEventSchemaMemo(schema)
55
58
 
56
59
  const syncStateRef = {
60
+ // The initial state is identical to the leader's initial state
57
61
  current: new SyncState.SyncState({
58
62
  localHead: clientSession.leaderThread.initialState.leaderHead,
59
63
  upstreamHead: clientSession.leaderThread.initialState.leaderHead,
64
+ // Given we're starting with the leader's snapshot, we don't have any pending mutations intially
60
65
  pending: [],
61
- // TODO init rollbackTail from leader to be ready for backend rebasing
62
- rollbackTail: [],
63
66
  }),
64
67
  }
65
68
 
66
69
  const syncStateUpdateQueue = Queue.unbounded<SyncState.SyncState>().pipe(Effect.runSync)
67
- const isLocalEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) =>
70
+ const isClientEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) =>
68
71
  getMutationDef(schema, mutationEventEncoded.mutation).options.clientOnly
69
72
 
70
73
  /** We're queuing push requests to reduce the number of messages sent to the leader by batching them */
@@ -74,13 +77,14 @@ export const makeClientSessionSyncProcessor = ({
74
77
  // TODO validate batch
75
78
 
76
79
  let baseEventId = syncStateRef.current.localHead
77
- const encodedMutationEvents = batch.map((mutationEvent) => {
78
- const mutationDef = getMutationDef(schema, mutationEvent.mutation)
80
+ const encodedMutationEvents = batch.map(({ mutation, args }) => {
81
+ const mutationDef = getMutationDef(schema, mutation)
79
82
  const nextIdPair = EventId.nextPair(baseEventId, mutationDef.options.clientOnly)
80
83
  baseEventId = nextIdPair.id
81
84
  return new MutationEvent.EncodedWithMeta(
82
85
  Schema.encodeUnknownSync(mutationEventSchema)({
83
- ...mutationEvent,
86
+ mutation,
87
+ args,
84
88
  ...nextIdPair,
85
89
  clientId: clientSession.clientId,
86
90
  sessionId: clientSession.sessionId,
@@ -88,31 +92,31 @@ export const makeClientSessionSyncProcessor = ({
88
92
  )
89
93
  })
90
94
 
91
- const updateResult = SyncState.updateSyncState({
95
+ const mergeResult = SyncState.merge({
92
96
  syncState: syncStateRef.current,
93
97
  payload: { _tag: 'local-push', newEvents: encodedMutationEvents },
94
- isLocalEvent,
98
+ isClientEvent,
95
99
  isEqualEvent: MutationEvent.isEqualEncoded,
96
100
  })
97
101
 
98
- if (updateResult._tag === 'unexpected-error') {
99
- return shouldNeverHappen('Unexpected error in client-session-sync-processor', updateResult.cause)
102
+ if (mergeResult._tag === 'unexpected-error') {
103
+ return shouldNeverHappen('Unexpected error in client-session-sync-processor', mergeResult.cause)
100
104
  }
101
105
 
102
106
  span.addEvent('local-push', {
103
107
  batchSize: encodedMutationEvents.length,
104
- updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
108
+ mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
105
109
  })
106
110
 
107
- if (updateResult._tag !== 'advance') {
108
- return shouldNeverHappen(`Expected advance, got ${updateResult._tag}`)
111
+ if (mergeResult._tag !== 'advance') {
112
+ return shouldNeverHappen(`Expected advance, got ${mergeResult._tag}`)
109
113
  }
110
114
 
111
- syncStateRef.current = updateResult.newSyncState
112
- syncStateUpdateQueue.offer(updateResult.newSyncState).pipe(Effect.runSync)
115
+ syncStateRef.current = mergeResult.newSyncState
116
+ syncStateUpdateQueue.offer(mergeResult.newSyncState).pipe(Effect.runSync)
113
117
 
114
118
  const writeTables = new Set<string>()
115
- for (const mutationEvent of updateResult.newEvents) {
119
+ for (const mutationEvent of mergeResult.newEvents) {
116
120
  // TODO avoid encoding and decoding here again
117
121
  const decodedMutationEvent = Schema.decodeSync(mutationEventSchema)(mutationEvent)
118
122
  const res = applyMutation(decodedMutationEvent, { otelContext, withChangeset: true })
@@ -166,38 +170,45 @@ export const makeClientSessionSyncProcessor = ({
166
170
 
167
171
  yield* FiberHandle.run(leaderPushingFiberHandle, backgroundLeaderPushing)
168
172
 
169
- yield* clientSession.leaderThread.mutations.pull.pipe(
170
- Stream.tap(({ payload, remaining }) =>
173
+ const getMergeCounter = () =>
174
+ clientSession.sqliteDb.select<{ mergeCounter: number }>(
175
+ sql`SELECT mergeCounter FROM ${LEADER_MERGE_COUNTER_TABLE} WHERE id = 0`,
176
+ )[0]?.mergeCounter ?? 0
177
+
178
+ // NOTE We need to lazily call `.pull` as we want the cursor to be updated
179
+ yield* Stream.suspend(() => clientSession.leaderThread.mutations.pull({ cursor: getMergeCounter() })).pipe(
180
+ Stream.tap(({ payload, mergeCounter: leaderMergeCounter }) =>
171
181
  Effect.gen(function* () {
172
- // console.log('pulled payload from leader', { payload, remaining })
182
+ // yield* Effect.logDebug('ClientSessionSyncProcessor:pull', payload)
183
+
173
184
  if (clientSession.devtools.enabled) {
174
185
  yield* clientSession.devtools.pullLatch.await
175
186
  }
176
187
 
177
- const updateResult = SyncState.updateSyncState({
188
+ const mergeResult = SyncState.merge({
178
189
  syncState: syncStateRef.current,
179
190
  payload,
180
- isLocalEvent,
191
+ isClientEvent,
181
192
  isEqualEvent: MutationEvent.isEqualEncoded,
182
193
  })
183
194
 
184
- if (updateResult._tag === 'unexpected-error') {
185
- return yield* Effect.fail(updateResult.cause)
186
- } else if (updateResult._tag === 'reject') {
187
- return shouldNeverHappen('Unexpected reject in client-session-sync-processor', updateResult)
195
+ if (mergeResult._tag === 'unexpected-error') {
196
+ return yield* Effect.fail(mergeResult.cause)
197
+ } else if (mergeResult._tag === 'reject') {
198
+ return shouldNeverHappen('Unexpected reject in client-session-sync-processor', mergeResult)
188
199
  }
189
200
 
190
- syncStateRef.current = updateResult.newSyncState
191
- syncStateUpdateQueue.offer(updateResult.newSyncState).pipe(Effect.runSync)
201
+ syncStateRef.current = mergeResult.newSyncState
202
+ syncStateUpdateQueue.offer(mergeResult.newSyncState).pipe(Effect.runSync)
192
203
 
193
- if (updateResult._tag === 'rebase') {
194
- span.addEvent('pull:rebase', {
204
+ if (mergeResult._tag === 'rebase') {
205
+ span.addEvent('merge:pull:rebase', {
195
206
  payloadTag: payload._tag,
196
207
  payload: TRACE_VERBOSE ? JSON.stringify(payload) : undefined,
197
- newEventsCount: updateResult.newEvents.length,
198
- rollbackCount: updateResult.eventsToRollback.length,
199
- res: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
200
- remaining,
208
+ newEventsCount: mergeResult.newEvents.length,
209
+ rollbackCount: mergeResult.rollbackEvents.length,
210
+ res: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
211
+ leaderMergeCounter,
201
212
  })
202
213
 
203
214
  debugInfo.rebaseCount++
@@ -211,37 +222,39 @@ export const makeClientSessionSyncProcessor = ({
211
222
 
212
223
  if (LS_DEV) {
213
224
  Effect.logDebug(
214
- 'pull:rebase: rollback',
215
- updateResult.eventsToRollback.length,
216
- ...updateResult.eventsToRollback.slice(0, 10).map((_) => _.toJSON()),
225
+ 'merge:pull:rebase: rollback',
226
+ mergeResult.rollbackEvents.length,
227
+ ...mergeResult.rollbackEvents.slice(0, 10).map((_) => _.toJSON()),
228
+ { leaderMergeCounter },
217
229
  ).pipe(Effect.provide(runtime), Effect.runSync)
218
230
  }
219
231
 
220
- for (let i = updateResult.eventsToRollback.length - 1; i >= 0; i--) {
221
- const event = updateResult.eventsToRollback[i]!
222
- if (event.meta.sessionChangeset) {
223
- rollback(event.meta.sessionChangeset)
224
- event.meta.sessionChangeset = undefined
232
+ for (let i = mergeResult.rollbackEvents.length - 1; i >= 0; i--) {
233
+ const event = mergeResult.rollbackEvents[i]!
234
+ if (event.meta.sessionChangeset._tag !== 'no-op' && event.meta.sessionChangeset._tag !== 'unset') {
235
+ rollback(event.meta.sessionChangeset.data)
236
+ event.meta.sessionChangeset = { _tag: 'unset' }
225
237
  }
226
238
  }
227
239
 
228
- yield* BucketQueue.offerAll(leaderPushQueue, updateResult.newSyncState.pending)
240
+ yield* BucketQueue.offerAll(leaderPushQueue, mergeResult.newSyncState.pending)
229
241
  } else {
230
- span.addEvent('pull:advance', {
242
+ span.addEvent('merge:pull:advance', {
231
243
  payloadTag: payload._tag,
232
244
  payload: TRACE_VERBOSE ? JSON.stringify(payload) : undefined,
233
- newEventsCount: updateResult.newEvents.length,
234
- res: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
235
- remaining,
245
+ newEventsCount: mergeResult.newEvents.length,
246
+ res: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
247
+ leaderMergeCounter,
236
248
  })
237
249
 
238
250
  debugInfo.advanceCount++
239
251
  }
240
252
 
241
- if (updateResult.newEvents.length === 0) return
253
+ if (mergeResult.newEvents.length === 0) return
242
254
 
243
255
  const writeTables = new Set<string>()
244
- for (const mutationEvent of updateResult.newEvents) {
256
+ for (const mutationEvent of mergeResult.newEvents) {
257
+ // TODO apply changeset if available (will require tracking of write tables as well)
245
258
  const decodedMutationEvent = Schema.decodeSync(mutationEventSchema)(mutationEvent)
246
259
  const res = applyMutation(decodedMutationEvent, { otelContext, withChangeset: true })
247
260
  for (const table of res.writeTables) {
@@ -259,6 +272,7 @@ export const makeClientSessionSyncProcessor = ({
259
272
  ),
260
273
  Stream.runDrain,
261
274
  Effect.forever, // NOTE Whenever the leader changes, we need to re-start the stream
275
+ Effect.interruptible,
262
276
  Effect.withSpan('client-session-sync-processor:pull'),
263
277
  Effect.tapCauseLogPretty,
264
278
  Effect.forkScoped,
package/src/sync/sync.ts CHANGED
@@ -19,6 +19,16 @@ export type SyncOptions = {
19
19
  backend?: SyncBackendConstructor<any>
20
20
  /** @default { _tag: 'Skip' } */
21
21
  initialSyncOptions?: InitialSyncOptions
22
+ /**
23
+ * What to do if there is an error during sync.
24
+ *
25
+ * Options:
26
+ * `shutdown` will stop the sync processor and cause the app to crash.
27
+ * `ignore` will log the error and let the app continue running acting as if it was offline.
28
+ *
29
+ * @default 'ignore'
30
+ * */
31
+ onSyncError?: 'shutdown' | 'ignore'
22
32
  }
23
33
 
24
34
  export type SyncBackendConstructor<TSyncMetadata = Schema.JsonValue> = (
@@ -26,6 +36,10 @@ export type SyncBackendConstructor<TSyncMetadata = Schema.JsonValue> = (
26
36
  ) => Effect.Effect<SyncBackend<TSyncMetadata>, UnexpectedError, Scope.Scope | HttpClient.HttpClient>
27
37
 
28
38
  export type SyncBackend<TSyncMetadata = Schema.JsonValue> = {
39
+ /**
40
+ * Can be implemented to prepare a connection to the sync backend to speed up the first pull/push.
41
+ */
42
+ connect: Effect.Effect<void, IsOfflineError | UnexpectedError, HttpClient.HttpClient | Scope.Scope>
29
43
  pull: (
30
44
  args: Option.Option<{
31
45
  cursor: EventId.EventId
@@ -50,17 +64,10 @@ export type SyncBackend<TSyncMetadata = Schema.JsonValue> = {
50
64
  * - event ids must be in ascending order
51
65
  * */
52
66
  batch: ReadonlyArray<MutationEvent.AnyEncodedGlobal>,
53
- ) => Effect.Effect<
54
- {
55
- /** Indexes are relative to `batch` */
56
- metadata: ReadonlyArray<Option.Option<TSyncMetadata>>
57
- },
58
- IsOfflineError | InvalidPushError,
59
- HttpClient.HttpClient
60
- >
67
+ ) => Effect.Effect<void, IsOfflineError | InvalidPushError, HttpClient.HttpClient>
61
68
  isConnected: SubscriptionRef.SubscriptionRef<boolean>
62
69
  /**
63
- * Metadata describing the sync backend.
70
+ * Metadata describing the sync backend. (Currently only used by devtools.)
64
71
  */
65
72
  metadata: { name: string; description: string } & Record<string, Schema.JsonValue>
66
73
  }