@livestore/common 0.4.0-dev.3 → 0.4.0-dev.6

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/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +4 -3
  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 +24 -24
  8. package/dist/errors.d.ts +17 -5
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/errors.js +14 -3
  11. package/dist/errors.js.map +1 -1
  12. package/dist/leader-thread/LeaderSyncProcessor.d.ts +4 -3
  13. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  14. package/dist/leader-thread/LeaderSyncProcessor.js +43 -24
  15. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  16. package/dist/leader-thread/eventlog.d.ts +4 -10
  17. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  18. package/dist/leader-thread/eventlog.js +3 -5
  19. package/dist/leader-thread/eventlog.js.map +1 -1
  20. package/dist/leader-thread/leader-worker-devtools.d.ts +1 -1
  21. package/dist/leader-thread/leader-worker-devtools.js +1 -1
  22. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  23. package/dist/leader-thread/make-leader-thread-layer.d.ts +1 -2
  24. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  25. package/dist/leader-thread/make-leader-thread-layer.js +40 -19
  26. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  27. package/dist/leader-thread/materialize-event.d.ts +2 -2
  28. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  29. package/dist/leader-thread/materialize-event.js +4 -6
  30. package/dist/leader-thread/materialize-event.js.map +1 -1
  31. package/dist/leader-thread/recreate-db.d.ts +2 -3
  32. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  33. package/dist/leader-thread/recreate-db.js +1 -1
  34. package/dist/leader-thread/recreate-db.js.map +1 -1
  35. package/dist/leader-thread/shutdown-channel.d.ts +2 -2
  36. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  37. package/dist/leader-thread/shutdown-channel.js +2 -2
  38. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  39. package/dist/leader-thread/types.d.ts +5 -5
  40. package/dist/leader-thread/types.d.ts.map +1 -1
  41. package/dist/materializer-helper.d.ts.map +1 -1
  42. package/dist/materializer-helper.js +8 -2
  43. package/dist/materializer-helper.js.map +1 -1
  44. package/dist/rematerialize-from-eventlog.d.ts +1 -1
  45. package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
  46. package/dist/schema/EventDef.d.ts +3 -0
  47. package/dist/schema/EventDef.d.ts.map +1 -1
  48. package/dist/schema/EventDef.js.map +1 -1
  49. package/dist/schema/LiveStoreEvent.d.ts +1 -1
  50. package/dist/schema/LiveStoreEvent.d.ts.map +1 -1
  51. package/dist/schema/LiveStoreEvent.js +1 -2
  52. package/dist/schema/LiveStoreEvent.js.map +1 -1
  53. package/dist/schema/schema.js +1 -1
  54. package/dist/schema/schema.js.map +1 -1
  55. package/dist/schema/state/sqlite/client-document-def.d.ts +30 -2
  56. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  57. package/dist/schema/state/sqlite/client-document-def.js +93 -2
  58. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  59. package/dist/schema/state/sqlite/client-document-def.test.js +3 -2
  60. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  61. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts +2 -1
  62. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts.map +1 -1
  63. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js +23 -6
  64. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js.map +1 -1
  65. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  66. package/dist/schema/state/sqlite/db-schema/dsl/mod.js +2 -1
  67. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  68. package/dist/schema/state/sqlite/mod.d.ts +1 -1
  69. package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
  70. package/dist/schema/state/sqlite/mod.js +1 -1
  71. package/dist/schema/state/sqlite/mod.js.map +1 -1
  72. package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
  73. package/dist/schema/state/sqlite/query-builder/impl.js +6 -2
  74. package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
  75. package/dist/schema/state/sqlite/query-builder/impl.test.js +56 -2
  76. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  77. package/dist/schema/state/sqlite/system-tables.d.ts +42 -6
  78. package/dist/schema/state/sqlite/system-tables.d.ts.map +1 -1
  79. package/dist/schema/state/sqlite/system-tables.js +2 -0
  80. package/dist/schema/state/sqlite/system-tables.js.map +1 -1
  81. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  82. package/dist/sql-queries/sql-query-builder.js +2 -1
  83. package/dist/sql-queries/sql-query-builder.js.map +1 -1
  84. package/dist/sync/ClientSessionSyncProcessor.d.ts +6 -9
  85. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  86. package/dist/sync/ClientSessionSyncProcessor.js +22 -22
  87. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  88. package/dist/sync/errors.d.ts +61 -0
  89. package/dist/sync/errors.d.ts.map +1 -0
  90. package/dist/sync/errors.js +36 -0
  91. package/dist/sync/errors.js.map +1 -0
  92. package/dist/sync/index.d.ts +1 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/mock-sync-backend.d.ts +14 -0
  97. package/dist/sync/mock-sync-backend.d.ts.map +1 -0
  98. package/dist/sync/mock-sync-backend.js +62 -0
  99. package/dist/sync/mock-sync-backend.js.map +1 -0
  100. package/dist/sync/next/history-dag.d.ts.map +1 -1
  101. package/dist/sync/next/history-dag.js +3 -1
  102. package/dist/sync/next/history-dag.js.map +1 -1
  103. package/dist/sync/sync-backend-kv.d.ts +7 -0
  104. package/dist/sync/sync-backend-kv.d.ts.map +1 -0
  105. package/dist/sync/sync-backend-kv.js +18 -0
  106. package/dist/sync/sync-backend-kv.js.map +1 -0
  107. package/dist/sync/sync-backend.d.ts +85 -0
  108. package/dist/sync/sync-backend.d.ts.map +1 -0
  109. package/dist/sync/sync-backend.js +24 -0
  110. package/dist/sync/sync-backend.js.map +1 -0
  111. package/dist/sync/sync.d.ts +6 -84
  112. package/dist/sync/sync.d.ts.map +1 -1
  113. package/dist/sync/sync.js +2 -27
  114. package/dist/sync/sync.js.map +1 -1
  115. package/dist/sync/validate-push-payload.d.ts +1 -1
  116. package/dist/sync/validate-push-payload.d.ts.map +1 -1
  117. package/dist/sync/validate-push-payload.js +6 -6
  118. package/dist/sync/validate-push-payload.js.map +1 -1
  119. package/dist/version.d.ts +2 -2
  120. package/dist/version.js +2 -2
  121. package/package.json +4 -4
  122. package/src/adapter-types.ts +8 -3
  123. package/src/errors.ts +24 -4
  124. package/src/leader-thread/LeaderSyncProcessor.ts +79 -30
  125. package/src/leader-thread/eventlog.ts +9 -5
  126. package/src/leader-thread/leader-worker-devtools.ts +1 -1
  127. package/src/leader-thread/make-leader-thread-layer.ts +70 -26
  128. package/src/leader-thread/materialize-event.ts +5 -6
  129. package/src/leader-thread/recreate-db.ts +11 -3
  130. package/src/leader-thread/shutdown-channel.ts +16 -2
  131. package/src/leader-thread/types.ts +5 -5
  132. package/src/materializer-helper.ts +9 -3
  133. package/src/schema/EventDef.ts +3 -0
  134. package/src/schema/LiveStoreEvent.ts +1 -2
  135. package/src/schema/schema.ts +1 -1
  136. package/src/schema/state/sqlite/client-document-def.test.ts +3 -2
  137. package/src/schema/state/sqlite/client-document-def.ts +108 -2
  138. package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +26 -6
  139. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +2 -1
  140. package/src/schema/state/sqlite/mod.ts +1 -0
  141. package/src/schema/state/sqlite/query-builder/impl.test.ts +66 -6
  142. package/src/schema/state/sqlite/query-builder/impl.ts +8 -2
  143. package/src/schema/state/sqlite/system-tables.ts +2 -0
  144. package/src/sql-queries/sql-query-builder.ts +2 -1
  145. package/src/sync/ClientSessionSyncProcessor.ts +37 -37
  146. package/src/sync/errors.ts +38 -0
  147. package/src/sync/index.ts +1 -0
  148. package/src/sync/mock-sync-backend.ts +96 -0
  149. package/src/sync/next/history-dag.ts +3 -1
  150. package/src/sync/sync-backend-kv.ts +22 -0
  151. package/src/sync/sync-backend.ts +137 -0
  152. package/src/sync/sync.ts +6 -89
  153. package/src/sync/validate-push-payload.ts +6 -7
  154. package/src/version.ts +2 -2
@@ -13,7 +13,7 @@ import { Context, Schema } from '@livestore/utils/effect'
13
13
  import type { MeshNode } from '@livestore/webmesh'
14
14
 
15
15
  import type { MigrationsReport } from '../defs.ts'
16
- import type { SqliteError } from '../errors.ts'
16
+ import type { MaterializeError } from '../errors.ts'
17
17
  import type {
18
18
  BootStatus,
19
19
  Devtools,
@@ -43,7 +43,7 @@ export const InitialSyncOptions = Schema.Union(InitialSyncOptionsSkip, InitialSy
43
43
  export type InitialSyncOptions = typeof InitialSyncOptions.Type
44
44
 
45
45
  export type InitialSyncInfo = Option.Option<{
46
- cursor: EventSequenceNumber.EventSequenceNumber
46
+ eventSequenceNumber: EventSequenceNumber.GlobalEventSequenceNumber
47
47
  metadata: Option.Option<Schema.JsonValue>
48
48
  }>
49
49
 
@@ -98,7 +98,7 @@ export class LeaderThreadCtx extends Context.Tag('LeaderThreadCtx')<
98
98
  shutdownChannel: ShutdownChannel
99
99
  eventSchema: LiveStoreEvent.ForEventDefRecord<any>
100
100
  devtools: DevtoolsContext
101
- syncBackend: SyncBackend | undefined
101
+ syncBackend: SyncBackend.SyncBackend | undefined
102
102
  syncProcessor: LeaderSyncProcessor
103
103
  materializeEvent: MaterializeEvent
104
104
  initialState: {
@@ -125,12 +125,12 @@ export type MaterializeEvent = (
125
125
  sessionChangeset: { _tag: 'sessionChangeset'; data: Uint8Array<ArrayBuffer>; debug: any } | { _tag: 'no-op' }
126
126
  hash: Option.Option<number>
127
127
  },
128
- SqliteError | UnexpectedError
128
+ MaterializeError
129
129
  >
130
130
 
131
131
  export type InitialBlockingSyncContext = {
132
132
  blockingDeferred: Deferred.Deferred<void> | undefined
133
- update: (_: { remaining: number; processed: number }) => Effect.Effect<void>
133
+ update: (_: { pageInfo: SyncBackend.PullResPageInfo; processed: number }) => Effect.Effect<void>
134
134
  }
135
135
 
136
136
  export interface LeaderSyncProcessor {
@@ -31,8 +31,13 @@ export const getExecStatementsFromMaterializer = ({
31
31
  bindValues: PreparedBindValues
32
32
  writeTables: ReadonlySet<string> | undefined
33
33
  }> => {
34
- const eventArgsDecoded =
35
- event.decoded === undefined ? Schema.decodeUnknownSync(eventDef.schema)(event.encoded!.args) : event.decoded.args
34
+ const eventDecoded =
35
+ event.decoded === undefined
36
+ ? {
37
+ ...event.encoded!,
38
+ args: Schema.decodeUnknownSync(eventDef.schema)(event.encoded!.args),
39
+ }
40
+ : event.decoded
36
41
 
37
42
  const eventArgsEncoded = isNil(event.decoded?.args)
38
43
  ? undefined
@@ -58,11 +63,12 @@ export const getExecStatementsFromMaterializer = ({
58
63
  }
59
64
 
60
65
  const statementResults = fromMaterializerResult(
61
- materializer(eventArgsDecoded, {
66
+ materializer(eventDecoded.args, {
62
67
  eventDef,
63
68
  query,
64
69
  // TODO properly implement this
65
70
  currentFacts: new Map(),
71
+ event: eventDecoded,
66
72
  }),
67
73
  )
68
74
 
@@ -4,6 +4,7 @@ import { Schema } from '@livestore/utils/effect'
4
4
 
5
5
  import type { BindValues } from '../sql-queries/sql-queries.ts'
6
6
  import type { ParamsObject } from '../util.ts'
7
+ import type * as LiveStoreEvent from './LiveStoreEvent.ts'
7
8
  import type { QueryBuilder } from './state/sqlite/query-builder/mod.ts'
8
9
 
9
10
  export type EventDefMap = {
@@ -191,6 +192,8 @@ export type Materializer<TEventDef extends EventDef.AnyWithoutFn = EventDef.AnyW
191
192
  eventDef: TEventDef
192
193
  /** Can be used to query the current state */
193
194
  query: MaterializerContextQuery
195
+ /** The full LiveStore event with clientId, sessionId, etc. */
196
+ event: LiveStoreEvent.AnyDecoded
194
197
  },
195
198
  ) => SingleOrReadonlyArray<MaterializerResult>
196
199
 
@@ -283,5 +283,4 @@ export const isEqualEncoded = (a: AnyEncoded, b: AnyEncoded) =>
283
283
  a.name === b.name &&
284
284
  a.clientId === b.clientId &&
285
285
  a.sessionId === b.sessionId &&
286
- // TODO use schema equality here
287
- JSON.stringify(a.args) === JSON.stringify(b.args)
286
+ JSON.stringify(a.args) === JSON.stringify(b.args) // TODO use schema equality here
@@ -110,7 +110,7 @@ export const getEventDef = <TSchema extends LiveStoreSchema>(
110
110
  } => {
111
111
  const eventDef = schema.eventsDefsMap.get(eventName)
112
112
  if (eventDef === undefined) {
113
- return shouldNeverHappen(`No mutation definition found for \`${eventName}\`.`)
113
+ return shouldNeverHappen(`No event definition found for \`${eventName}\`.`)
114
114
  }
115
115
  const materializer = schema.state.materializers.get(eventName)
116
116
  if (materializer === undefined) {
@@ -50,6 +50,7 @@ describe('client document table', () => {
50
50
  currentFacts: new Map(),
51
51
  query: {} as any, // unused
52
52
  eventDef: Doc[ClientDocumentTableDefSymbol].derived.setEventDef,
53
+ event: {} as any, // unused in this test
53
54
  })
54
55
  }
55
56
 
@@ -236,8 +237,8 @@ describe('client document table', () => {
236
237
  {
237
238
  "bindValues": [
238
239
  "id1",
239
- "{\"a\":1}",
240
- "{\"a\":1}",
240
+ "{"a":1}",
241
+ "{"a":1}",
241
242
  ],
242
243
  "sql": "INSERT INTO 'test' (id, value) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET value = ?",
243
244
  "writeTables": Set {
@@ -62,9 +62,16 @@ export const clientDocument = <
62
62
  },
63
63
  } satisfies ClientDocumentTableOptions<TType>
64
64
 
65
+ // Column needs optimistic schema to read historical data formats
66
+ const optimisticColumnSchema = createOptimisticEventSchema({
67
+ valueSchema,
68
+ defaultValue: options.default.value,
69
+ partialSet: false, // Column always stores full documents
70
+ })
71
+
65
72
  const columns = {
66
73
  id: SqliteDsl.text({ primaryKey: true }),
67
- value: SqliteDsl.json({ schema: valueSchema }),
74
+ value: SqliteDsl.json({ schema: optimisticColumnSchema }),
68
75
  }
69
76
 
70
77
  const tableDef = table({ name, columns })
@@ -140,6 +147,105 @@ export const mergeDefaultValues = <T>(defaultValues: T, explicitDefaultValues: T
140
147
  }, {} as any)
141
148
  }
142
149
 
150
+ /**
151
+ * Creates an optimistic schema that accepts historical event formats
152
+ * and transforms them to the current schema, preserving data and intent.
153
+ *
154
+ * Decision Matrix for Schema Changes:
155
+ *
156
+ * | Change Type | Partial Set | Full Set | Strategy |
157
+ * |---------------------|---------------------|----------------------------------|-------------------------|
158
+ * | **Compatible Changes** |
159
+ * | Add optional field | Preserve existing | Preserve existing, new field undefined | Direct decode or merge |
160
+ * | Add required field | Preserve existing | Preserve existing, new field from default | Merge with defaults |
161
+ * | **Incompatible Changes** |
162
+ * | Remove field | Drop removed field | Drop removed field, preserve others | Filter & decode |
163
+ * | Type change | Use default for field | Use default for changed field | Selective merge |
164
+ * | Rename field | Use default | Use default (can't detect rename) | Fall back to default |
165
+ * | **Edge Cases** |
166
+ * | Empty event | Return {} | Return full default | Fallback handling |
167
+ * | Invalid structure | Return {} | Return full default | Fallback handling |
168
+ */
169
+ export const createOptimisticEventSchema = ({
170
+ valueSchema,
171
+ defaultValue,
172
+ partialSet,
173
+ }: {
174
+ valueSchema: Schema.Schema<any, any>
175
+ defaultValue: any
176
+ partialSet: boolean
177
+ }) => {
178
+ const targetSchema = partialSet ? Schema.partial(valueSchema) : valueSchema
179
+
180
+ return Schema.transform(
181
+ Schema.Unknown, // Accept any historical event structure
182
+ targetSchema, // Output current schema
183
+ {
184
+ decode: (eventValue) => {
185
+ // Try direct decode first (for current schema events)
186
+ try {
187
+ return Schema.decodeUnknownSync(targetSchema)(eventValue)
188
+ } catch {
189
+ // Optimistic decoding for historical events
190
+
191
+ // Handle null/undefined/non-object cases
192
+ if (typeof eventValue !== 'object' || eventValue === null) {
193
+ console.warn(`Client document: Non-object event value, using ${partialSet ? 'empty partial' : 'defaults'}`)
194
+ return partialSet ? {} : defaultValue
195
+ }
196
+
197
+ if (partialSet) {
198
+ // For partial sets: only preserve fields that exist in new schema
199
+ const partialResult: Record<string, unknown> = {}
200
+ let hasValidFields = false
201
+
202
+ for (const [key, value] of Object.entries(eventValue as Record<string, unknown>)) {
203
+ if (key in defaultValue) {
204
+ partialResult[key] = value
205
+ hasValidFields = true
206
+ }
207
+ // Drop fields that don't exist in new schema
208
+ }
209
+
210
+ if (hasValidFields) {
211
+ try {
212
+ return Schema.decodeUnknownSync(targetSchema)(partialResult)
213
+ } catch {
214
+ // Even filtered fields don't match schema
215
+ console.warn('Client document: Partial fields incompatible, returning empty partial')
216
+ return {}
217
+ }
218
+ }
219
+ return {}
220
+ } else {
221
+ // Full set: merge old data with new defaults
222
+ const merged: Record<string, unknown> = { ...defaultValue }
223
+
224
+ // Override defaults with valid fields from old event
225
+ for (const [key, value] of Object.entries(eventValue as Record<string, unknown>)) {
226
+ if (key in defaultValue) {
227
+ merged[key] = value
228
+ }
229
+ // Drop fields that don't exist in new schema
230
+ }
231
+
232
+ // Try to decode the merged value
233
+ try {
234
+ return Schema.decodeUnknownSync(valueSchema)(merged)
235
+ } catch {
236
+ // Merged value still doesn't match (e.g., type changes)
237
+ // Fall back to pure defaults
238
+ console.warn('Client document: Could not preserve event data, using defaults')
239
+ return defaultValue
240
+ }
241
+ }
242
+ }
243
+ },
244
+ encode: (value) => value, // Pass-through for encoding
245
+ },
246
+ )
247
+ }
248
+
143
249
  export const deriveEventAndMaterializer = ({
144
250
  name,
145
251
  valueSchema,
@@ -155,7 +261,7 @@ export const deriveEventAndMaterializer = ({
155
261
  name: `${name}Set`,
156
262
  schema: Schema.Struct({
157
263
  id: Schema.Union(Schema.String, Schema.UniqueSymbolFromSelf(SessionIdSymbol)),
158
- value: partialSet ? Schema.partial(valueSchema) : valueSchema,
264
+ value: createOptimisticEventSchema({ valueSchema, defaultValue, partialSet }),
159
265
  }).annotations({ title: `${name}Set:Args` }),
160
266
  clientOnly: true,
161
267
  derived: true,
@@ -1,4 +1,5 @@
1
- import { type Option, Schema } from '@livestore/utils/effect'
1
+ import { omitUndefineds } from '@livestore/utils'
2
+ import { type Option, Schema, SchemaAST } from '@livestore/utils/effect'
2
3
 
3
4
  import { hashCode } from '../hash.ts'
4
5
 
@@ -45,9 +46,7 @@ export const index = (
45
46
  ): Index => ({
46
47
  _tag: 'index',
47
48
  columns,
48
- name,
49
- unique,
50
- primaryKey,
49
+ ...omitUndefineds({ name, unique, primaryKey }),
51
50
  })
52
51
 
53
52
  export type ForeignKey = {
@@ -85,7 +84,19 @@ export type DbSchema = {
85
84
  export const dbSchema = (tables: Table[]): DbSchema => ({ _tag: 'dbSchema', tables })
86
85
 
87
86
  /**
88
- * NOTE we're only including SQLite-relevant information in the hash (which excludes the schema mapping)
87
+ * Helper to detect if a column is a JSON column (has parseJson transformation)
88
+ */
89
+ const isJsonColumn = (column: Column): boolean => {
90
+ if (column.type._tag !== 'text') return false
91
+
92
+ // Check if the schema AST is a parseJson transformation
93
+ const ast = column.schema.ast
94
+ return ast._tag === 'Transformation' && ast.annotations.schemaId === SchemaAST.ParseJsonSchemaId
95
+ }
96
+
97
+ /**
98
+ * NOTE we're now including JSON schema information for JSON columns
99
+ * to detect client document schema changes
89
100
  */
90
101
  export const hash = (obj: Table | Column | Index | ForeignKey | DbSchema): number =>
91
102
  hashCode(JSON.stringify(trimInfoForHasing(obj)))
@@ -101,7 +112,7 @@ const trimInfoForHasing = (obj: Table | Column | Index | ForeignKey | DbSchema):
101
112
  }
102
113
  }
103
114
  case 'column': {
104
- return {
115
+ const baseInfo: Record<string, any> = {
105
116
  _tag: 'column',
106
117
  name: obj.name,
107
118
  type: obj.type._tag,
@@ -110,6 +121,15 @@ const trimInfoForHasing = (obj: Table | Column | Index | ForeignKey | DbSchema):
110
121
  autoIncrement: obj.autoIncrement,
111
122
  default: obj.default,
112
123
  }
124
+
125
+ // NEW: Include schema hash for JSON columns
126
+ // This ensures that changes to the JSON schema are detected
127
+ if (isJsonColumn(obj) && obj.schema) {
128
+ // Use Effect's Schema.hash for consistent hashing
129
+ baseInfo.jsonSchemaHash = Schema.hash(obj.schema)
130
+ }
131
+
132
+ return baseInfo
113
133
  }
114
134
  case 'index': {
115
135
  return {
@@ -1,4 +1,5 @@
1
1
  import type { Nullable } from '@livestore/utils'
2
+ import { omitUndefineds } from '@livestore/utils'
2
3
  import type { Option, Types } from '@livestore/utils/effect'
3
4
  import { Schema } from '@livestore/utils/effect'
4
5
 
@@ -46,7 +47,7 @@ export const table = <TTableName extends string, TColumns extends Columns, TInde
46
47
  indexes: indexesToAst(indexes ?? []),
47
48
  }
48
49
 
49
- return { name, columns, indexes, ast }
50
+ return { name, columns, ...omitUndefineds({ indexes }), ast }
50
51
  }
51
52
 
52
53
  export type AnyIfConstained<In, Out> = '__constrained' extends keyof In ? any : Out
@@ -14,6 +14,7 @@ export {
14
14
  ClientDocumentTableDefSymbol,
15
15
  type ClientDocumentTableOptions,
16
16
  clientDocument,
17
+ createOptimisticEventSchema,
17
18
  tableIsClientDocumentTable,
18
19
  } from './client-document-def.ts'
19
20
  export * from './column-annotations.ts'
@@ -409,9 +409,7 @@ describe('query builder', () => {
409
409
  })
410
410
 
411
411
  it('should handle INSERT queries with undefined values', () => {
412
- expect(
413
- dump(db.todos.insert({ id: '123', text: 'Buy milk', status: 'active', completed: undefined })),
414
- ).toMatchInlineSnapshot(`
412
+ expect(dump(db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }))).toMatchInlineSnapshot(`
415
413
  {
416
414
  "bindValues": [
417
415
  "123",
@@ -478,9 +476,7 @@ describe('query builder', () => {
478
476
  })
479
477
 
480
478
  it('should handle UPDATE queries with undefined values', () => {
481
- expect(
482
- dump(db.todos.update({ status: undefined, text: 'some text' }).where({ id: '123' })),
483
- ).toMatchInlineSnapshot(`
479
+ expect(dump(db.todos.update({ text: 'some text' }).where({ id: '123' }))).toMatchInlineSnapshot(`
484
480
  {
485
481
  "bindValues": [
486
482
  "some text",
@@ -622,6 +618,70 @@ describe('query builder', () => {
622
618
  }
623
619
  `)
624
620
  })
621
+
622
+ it('should handle where().delete() - preserving where clauses', () => {
623
+ expect(dump(db.todos.where({ status: 'completed' }).delete())).toMatchInlineSnapshot(`
624
+ {
625
+ "bindValues": [
626
+ "completed",
627
+ ],
628
+ "query": "DELETE FROM 'todos' WHERE status = ?",
629
+ "schema": "number",
630
+ }
631
+ `)
632
+
633
+ // Multiple where clauses
634
+ expect(dump(db.todos.where({ status: 'completed' }).where({ deletedAt: null }).delete())).toMatchInlineSnapshot(`
635
+ {
636
+ "bindValues": [
637
+ "completed",
638
+ ],
639
+ "query": "DELETE FROM 'todos' WHERE status = ? AND deletedAt IS NULL",
640
+ "schema": "number",
641
+ }
642
+ `)
643
+ })
644
+
645
+ it('should handle where().update() - preserving where clauses', () => {
646
+ expect(dump(db.todos.where({ id: '123' }).update({ status: 'completed' }))).toMatchInlineSnapshot(`
647
+ {
648
+ "bindValues": [
649
+ "completed",
650
+ "123",
651
+ ],
652
+ "query": "UPDATE 'todos' SET status = ? WHERE id = ?",
653
+ "schema": "number",
654
+ }
655
+ `)
656
+
657
+ // Multiple where clauses
658
+ expect(
659
+ dump(db.todos.where({ id: '123' }).where({ deletedAt: null }).update({ status: 'completed' })),
660
+ ).toMatchInlineSnapshot(`
661
+ {
662
+ "bindValues": [
663
+ "completed",
664
+ "123",
665
+ ],
666
+ "query": "UPDATE 'todos' SET status = ? WHERE id = ? AND deletedAt IS NULL",
667
+ "schema": "number",
668
+ }
669
+ `)
670
+ })
671
+
672
+ it('should have equivalent behavior for both delete patterns', () => {
673
+ const pattern1 = dump(db.todos.where({ status: 'completed', id: '123' }).delete())
674
+ const pattern2 = dump(db.todos.delete().where({ status: 'completed', id: '123' }))
675
+
676
+ expect(pattern1).toEqual(pattern2)
677
+ })
678
+
679
+ it('should have equivalent behavior for both update patterns', () => {
680
+ const pattern1 = dump(db.todos.where({ id: '123' }).update({ status: 'completed', text: 'Updated' }))
681
+ const pattern2 = dump(db.todos.update({ status: 'completed', text: 'Updated' }).where({ id: '123' }))
682
+
683
+ expect(pattern1).toEqual(pattern2)
684
+ })
625
685
  })
626
686
  })
627
687
 
@@ -219,21 +219,27 @@ export const makeQueryBuilder = <TResult, TTableDef extends TableDefBase>(
219
219
  update: (values) => {
220
220
  const filteredValues = Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined))
221
221
 
222
+ // Preserve where clauses if coming from a SelectQuery
223
+ const whereClause = ast._tag === 'SelectQuery' ? ast.where : []
224
+
222
225
  return makeQueryBuilder(tableDef, {
223
226
  _tag: 'UpdateQuery',
224
227
  tableDef,
225
228
  values: filteredValues,
226
- where: [],
229
+ where: whereClause,
227
230
  returning: undefined,
228
231
  resultSchema: Schema.Void,
229
232
  }) as any
230
233
  },
231
234
 
232
235
  delete: () => {
236
+ // Preserve where clauses if coming from a SelectQuery
237
+ const whereClause = ast._tag === 'SelectQuery' ? ast.where : []
238
+
233
239
  return makeQueryBuilder(tableDef, {
234
240
  _tag: 'DeleteQuery',
235
241
  tableDef,
236
- where: [],
242
+ where: whereClause,
237
243
  returning: undefined,
238
244
  resultSchema: Schema.Void,
239
245
  }) as any
@@ -96,6 +96,8 @@ export const syncStatusTable = table({
96
96
  name: SYNC_STATUS_TABLE,
97
97
  columns: {
98
98
  head: SqliteDsl.integer({ primaryKey: true }),
99
+ // Null means the sync backend is not yet connected and we haven't yet seen a backend ID
100
+ backendId: SqliteDsl.text({ nullable: true }),
99
101
  },
100
102
  })
101
103
 
@@ -1,3 +1,4 @@
1
+ import { omitUndefineds } from '@livestore/utils'
1
2
  import type { SqliteDsl } from '../schema/state/sqlite/db-schema/mod.ts'
2
3
  import type { BindValues } from './sql-queries.ts'
3
4
  import * as SqlQueries from './sql-queries.ts'
@@ -16,7 +17,7 @@ export const makeSqlQueryBuilder = <TSchema extends SqliteDsl.DbSchema>(schema:
16
17
  limit?: number
17
18
  }): [string, BindValues, TTableName] => {
18
19
  const columns = schema[tableName]!.columns
19
- const [stmt, bindValues] = SqlQueries.findManyRows({ columns, tableName, where, limit })
20
+ const [stmt, bindValues] = SqlQueries.findManyRows({ columns, tableName, where, ...omitUndefineds({ limit }) })
20
21
  return [stmt, bindValues, tableName]
21
22
  }
22
23
 
@@ -13,9 +13,9 @@ import {
13
13
  Stream,
14
14
  Subscribable,
15
15
  } from '@livestore/utils/effect'
16
- import * as otel from '@opentelemetry/api'
16
+ import type * as otel from '@opentelemetry/api'
17
17
 
18
- import { type ClientSession, SyncError, type UnexpectedError } from '../adapter-types.ts'
18
+ import { type ClientSession, type MaterializeError, UnexpectedError } from '../adapter-types.ts'
19
19
  import * as EventSequenceNumber from '../schema/EventSequenceNumber.ts'
20
20
  import * as LiveStoreEvent from '../schema/LiveStoreEvent.ts'
21
21
  import { getEventDef, type LiveStoreSchema } from '../schema/mod.ts'
@@ -52,15 +52,18 @@ export const makeClientSessionSyncProcessor = ({
52
52
  runtime: Runtime.Runtime<Scope.Scope>
53
53
  materializeEvent: (
54
54
  eventDecoded: LiveStoreEvent.AnyDecoded,
55
- options: { otelContext: otel.Context; withChangeset: boolean; materializerHashLeader: Option.Option<number> },
56
- ) => {
57
- writeTables: Set<string>
58
- sessionChangeset:
59
- | { _tag: 'sessionChangeset'; data: Uint8Array<ArrayBuffer>; debug: any }
60
- | { _tag: 'no-op' }
61
- | { _tag: 'unset' }
62
- materializerHash: Option.Option<number>
63
- }
55
+ options: { withChangeset: boolean; materializerHashLeader: Option.Option<number> },
56
+ ) => Effect.Effect<
57
+ {
58
+ writeTables: Set<string>
59
+ sessionChangeset:
60
+ | { _tag: 'sessionChangeset'; data: Uint8Array<ArrayBuffer>; debug: any }
61
+ | { _tag: 'no-op' }
62
+ | { _tag: 'unset' }
63
+ materializerHash: Option.Option<number>
64
+ },
65
+ MaterializeError
66
+ >
64
67
  rollback: (changeset: Uint8Array<ArrayBuffer>) => void
65
68
  refreshTables: (tables: Set<string>) => void
66
69
  span: otel.Span
@@ -99,7 +102,7 @@ export const makeClientSessionSyncProcessor = ({
99
102
  /** We're queuing push requests to reduce the number of messages sent to the leader by batching them */
100
103
  const leaderPushQueue = BucketQueue.make<LiveStoreEvent.EncodedWithMeta>().pipe(Effect.runSync)
101
104
 
102
- const push: ClientSessionSyncProcessor['push'] = (batch, { otelContext }) => {
105
+ const push: ClientSessionSyncProcessor['push'] = Effect.fn('client-session-sync-processor:push')(function* (batch) {
103
106
  // TODO validate batch
104
107
 
105
108
  let baseEventSequenceNumber = syncStateRef.current.localHead
@@ -128,21 +131,26 @@ export const makeClientSessionSyncProcessor = ({
128
131
  isEqualEvent: LiveStoreEvent.isEqualEncoded,
129
132
  })
130
133
 
134
+ yield* Effect.annotateCurrentSpan({
135
+ batchSize: encodedEventDefs.length,
136
+ mergeResultTag: mergeResult._tag,
137
+ eventCounts: encodedEventDefs.reduce<Record<string, number>>((acc, event) => {
138
+ acc[event.name] = (acc[event.name] ?? 0) + 1
139
+ return acc
140
+ }, {}),
141
+ ...(TRACE_VERBOSE && { mergeResult: JSON.stringify(mergeResult) }),
142
+ })
143
+
131
144
  if (mergeResult._tag === 'unexpected-error') {
132
145
  return shouldNeverHappen('Unexpected error in client-session-sync-processor', mergeResult.message)
133
146
  }
134
147
 
135
- span.addEvent('local-push', {
136
- batchSize: encodedEventDefs.length,
137
- mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
138
- })
139
-
140
148
  if (mergeResult._tag !== 'advance') {
141
149
  return shouldNeverHappen(`Expected advance, got ${mergeResult._tag}`)
142
150
  }
143
151
 
144
152
  syncStateRef.current = mergeResult.newSyncState
145
- syncStateUpdateQueue.offer(mergeResult.newSyncState).pipe(Effect.runSync)
153
+ yield* syncStateUpdateQueue.offer(mergeResult.newSyncState)
146
154
 
147
155
  // Materialize events to state
148
156
  const writeTables = new Set<string>()
@@ -153,8 +161,7 @@ export const makeClientSessionSyncProcessor = ({
153
161
  writeTables: newWriteTables,
154
162
  sessionChangeset,
155
163
  materializerHash,
156
- } = materializeEvent(decodedEventDef, {
157
- otelContext,
164
+ } = yield* materializeEvent(decodedEventDef, {
158
165
  withChangeset: true,
159
166
  materializerHashLeader: Option.none(),
160
167
  })
@@ -167,10 +174,10 @@ export const makeClientSessionSyncProcessor = ({
167
174
 
168
175
  // Trigger push to leader
169
176
  // console.debug('pushToLeader', encodedEventDefs.length, ...encodedEventDefs.map((_) => _.toJSON()))
170
- BucketQueue.offerAll(leaderPushQueue, encodedEventDefs).pipe(Effect.runSync)
177
+ yield* BucketQueue.offerAll(leaderPushQueue, encodedEventDefs)
171
178
 
172
179
  return { writeTables }
173
- }
180
+ })
174
181
 
175
182
  const debugInfo = {
176
183
  rebaseCount: 0,
@@ -178,8 +185,6 @@ export const makeClientSessionSyncProcessor = ({
178
185
  rejectCount: 0,
179
186
  }
180
187
 
181
- const otelContext = otel.trace.setSpan(otel.context.active(), span)
182
-
183
188
  const boot: ClientSessionSyncProcessor['boot'] = Effect.gen(function* () {
184
189
  if (confirmUnsavedChanges && typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
185
190
  const onBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -229,7 +234,7 @@ export const makeClientSessionSyncProcessor = ({
229
234
  })
230
235
 
231
236
  if (mergeResult._tag === 'unexpected-error') {
232
- return yield* new SyncError({ cause: mergeResult.message })
237
+ return yield* new UnexpectedError({ cause: mergeResult.message })
233
238
  } else if (mergeResult._tag === 'reject') {
234
239
  return shouldNeverHappen('Unexpected reject in client-session-sync-processor', mergeResult)
235
240
  }
@@ -244,7 +249,6 @@ export const makeClientSessionSyncProcessor = ({
244
249
  newEventsCount: mergeResult.newEvents.length,
245
250
  rollbackCount: mergeResult.rollbackEvents.length,
246
251
  res: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
247
- rebaseGeneration: mergeResult.newSyncState.localHead.rebaseGeneration,
248
252
  })
249
253
 
250
254
  debugInfo.rebaseCount++
@@ -304,8 +308,7 @@ export const makeClientSessionSyncProcessor = ({
304
308
  writeTables: newWriteTables,
305
309
  sessionChangeset,
306
310
  materializerHash,
307
- } = materializeEvent(decodedEventDef, {
308
- otelContext,
311
+ } = yield* materializeEvent(decodedEventDef, {
309
312
  withChangeset: true,
310
313
  materializerHashLeader: event.meta.materializerHashLeader,
311
314
  })
@@ -364,10 +367,7 @@ export const makeClientSessionSyncProcessor = ({
364
367
  export interface ClientSessionSyncProcessor {
365
368
  push: (
366
369
  batch: ReadonlyArray<LiveStoreEvent.PartialAnyDecoded>,
367
- options: { otelContext: otel.Context },
368
- ) => {
369
- writeTables: Set<string>
370
- }
370
+ ) => Effect.Effect<{ writeTables: Set<string> }, MaterializeError>
371
371
  boot: Effect.Effect<void, UnexpectedError, Scope.Scope>
372
372
  /**
373
373
  * Only used for debugging / observability.
@@ -388,11 +388,11 @@ const SIMULATION_ENABLED = true
388
388
  // Warning: High values for the simulation params can lead to very long test runs since those get multiplied with the number of events
389
389
  export const ClientSessionSyncProcessorSimulationParams = Schema.Struct({
390
390
  pull: Schema.Struct({
391
- '1_before_leader_push_fiber_interrupt': Schema.Int.pipe(Schema.between(0, 25)),
392
- '2_before_leader_push_queue_clear': Schema.Int.pipe(Schema.between(0, 25)),
393
- '3_before_rebase_rollback': Schema.Int.pipe(Schema.between(0, 25)),
394
- '4_before_leader_push_queue_offer': Schema.Int.pipe(Schema.between(0, 25)),
395
- '5_before_leader_push_fiber_run': Schema.Int.pipe(Schema.between(0, 25)),
391
+ '1_before_leader_push_fiber_interrupt': Schema.Int.pipe(Schema.between(0, 15)),
392
+ '2_before_leader_push_queue_clear': Schema.Int.pipe(Schema.between(0, 15)),
393
+ '3_before_rebase_rollback': Schema.Int.pipe(Schema.between(0, 15)),
394
+ '4_before_leader_push_queue_offer': Schema.Int.pipe(Schema.between(0, 15)),
395
+ '5_before_leader_push_fiber_run': Schema.Int.pipe(Schema.between(0, 15)),
396
396
  }),
397
397
  })
398
398
  type ClientSessionSyncProcessorSimulationParams = typeof ClientSessionSyncProcessorSimulationParams.Type