@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapter-types.d.ts +4 -3
- package/dist/adapter-types.d.ts.map +1 -1
- package/dist/adapter-types.js.map +1 -1
- package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
- package/dist/devtools/devtools-messages-common.d.ts +6 -6
- package/dist/devtools/devtools-messages-leader.d.ts +24 -24
- package/dist/errors.d.ts +17 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -3
- package/dist/errors.js.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.d.ts +4 -3
- package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.js +43 -24
- package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
- package/dist/leader-thread/eventlog.d.ts +4 -10
- package/dist/leader-thread/eventlog.d.ts.map +1 -1
- package/dist/leader-thread/eventlog.js +3 -5
- package/dist/leader-thread/eventlog.js.map +1 -1
- package/dist/leader-thread/leader-worker-devtools.d.ts +1 -1
- package/dist/leader-thread/leader-worker-devtools.js +1 -1
- package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.d.ts +1 -2
- package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.js +40 -19
- package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
- package/dist/leader-thread/materialize-event.d.ts +2 -2
- package/dist/leader-thread/materialize-event.d.ts.map +1 -1
- package/dist/leader-thread/materialize-event.js +4 -6
- package/dist/leader-thread/materialize-event.js.map +1 -1
- package/dist/leader-thread/recreate-db.d.ts +2 -3
- package/dist/leader-thread/recreate-db.d.ts.map +1 -1
- package/dist/leader-thread/recreate-db.js +1 -1
- package/dist/leader-thread/recreate-db.js.map +1 -1
- package/dist/leader-thread/shutdown-channel.d.ts +2 -2
- package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
- package/dist/leader-thread/shutdown-channel.js +2 -2
- package/dist/leader-thread/shutdown-channel.js.map +1 -1
- package/dist/leader-thread/types.d.ts +5 -5
- package/dist/leader-thread/types.d.ts.map +1 -1
- package/dist/materializer-helper.d.ts.map +1 -1
- package/dist/materializer-helper.js +8 -2
- package/dist/materializer-helper.js.map +1 -1
- package/dist/rematerialize-from-eventlog.d.ts +1 -1
- package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
- package/dist/schema/EventDef.d.ts +3 -0
- package/dist/schema/EventDef.d.ts.map +1 -1
- package/dist/schema/EventDef.js.map +1 -1
- package/dist/schema/LiveStoreEvent.d.ts +1 -1
- package/dist/schema/LiveStoreEvent.d.ts.map +1 -1
- package/dist/schema/LiveStoreEvent.js +1 -2
- package/dist/schema/LiveStoreEvent.js.map +1 -1
- package/dist/schema/schema.js +1 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.d.ts +30 -2
- package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.js +93 -2
- package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.test.js +3 -2
- package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
- package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts +2 -1
- package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts.map +1 -1
- package/dist/schema/state/sqlite/db-schema/ast/sqlite.js +23 -6
- package/dist/schema/state/sqlite/db-schema/ast/sqlite.js.map +1 -1
- package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
- package/dist/schema/state/sqlite/db-schema/dsl/mod.js +2 -1
- package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
- package/dist/schema/state/sqlite/mod.d.ts +1 -1
- package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
- package/dist/schema/state/sqlite/mod.js +1 -1
- package/dist/schema/state/sqlite/mod.js.map +1 -1
- package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
- package/dist/schema/state/sqlite/query-builder/impl.js +6 -2
- package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
- package/dist/schema/state/sqlite/query-builder/impl.test.js +56 -2
- package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
- package/dist/schema/state/sqlite/system-tables.d.ts +42 -6
- package/dist/schema/state/sqlite/system-tables.d.ts.map +1 -1
- package/dist/schema/state/sqlite/system-tables.js +2 -0
- package/dist/schema/state/sqlite/system-tables.js.map +1 -1
- package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
- package/dist/sql-queries/sql-query-builder.js +2 -1
- package/dist/sql-queries/sql-query-builder.js.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.d.ts +6 -9
- package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.js +22 -22
- package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
- package/dist/sync/errors.d.ts +61 -0
- package/dist/sync/errors.d.ts.map +1 -0
- package/dist/sync/errors.js +36 -0
- package/dist/sync/errors.js.map +1 -0
- package/dist/sync/index.d.ts +1 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/mock-sync-backend.d.ts +14 -0
- package/dist/sync/mock-sync-backend.d.ts.map +1 -0
- package/dist/sync/mock-sync-backend.js +62 -0
- package/dist/sync/mock-sync-backend.js.map +1 -0
- package/dist/sync/next/history-dag.d.ts.map +1 -1
- package/dist/sync/next/history-dag.js +3 -1
- package/dist/sync/next/history-dag.js.map +1 -1
- package/dist/sync/sync-backend-kv.d.ts +7 -0
- package/dist/sync/sync-backend-kv.d.ts.map +1 -0
- package/dist/sync/sync-backend-kv.js +18 -0
- package/dist/sync/sync-backend-kv.js.map +1 -0
- package/dist/sync/sync-backend.d.ts +85 -0
- package/dist/sync/sync-backend.d.ts.map +1 -0
- package/dist/sync/sync-backend.js +24 -0
- package/dist/sync/sync-backend.js.map +1 -0
- package/dist/sync/sync.d.ts +6 -84
- package/dist/sync/sync.d.ts.map +1 -1
- package/dist/sync/sync.js +2 -27
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/validate-push-payload.d.ts +1 -1
- package/dist/sync/validate-push-payload.d.ts.map +1 -1
- package/dist/sync/validate-push-payload.js +6 -6
- package/dist/sync/validate-push-payload.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +4 -4
- package/src/adapter-types.ts +8 -3
- package/src/errors.ts +24 -4
- package/src/leader-thread/LeaderSyncProcessor.ts +79 -30
- package/src/leader-thread/eventlog.ts +9 -5
- package/src/leader-thread/leader-worker-devtools.ts +1 -1
- package/src/leader-thread/make-leader-thread-layer.ts +70 -26
- package/src/leader-thread/materialize-event.ts +5 -6
- package/src/leader-thread/recreate-db.ts +11 -3
- package/src/leader-thread/shutdown-channel.ts +16 -2
- package/src/leader-thread/types.ts +5 -5
- package/src/materializer-helper.ts +9 -3
- package/src/schema/EventDef.ts +3 -0
- package/src/schema/LiveStoreEvent.ts +1 -2
- package/src/schema/schema.ts +1 -1
- package/src/schema/state/sqlite/client-document-def.test.ts +3 -2
- package/src/schema/state/sqlite/client-document-def.ts +108 -2
- package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +26 -6
- package/src/schema/state/sqlite/db-schema/dsl/mod.ts +2 -1
- package/src/schema/state/sqlite/mod.ts +1 -0
- package/src/schema/state/sqlite/query-builder/impl.test.ts +66 -6
- package/src/schema/state/sqlite/query-builder/impl.ts +8 -2
- package/src/schema/state/sqlite/system-tables.ts +2 -0
- package/src/sql-queries/sql-query-builder.ts +2 -1
- package/src/sync/ClientSessionSyncProcessor.ts +37 -37
- package/src/sync/errors.ts +38 -0
- package/src/sync/index.ts +1 -0
- package/src/sync/mock-sync-backend.ts +96 -0
- package/src/sync/next/history-dag.ts +3 -1
- package/src/sync/sync-backend-kv.ts +22 -0
- package/src/sync/sync-backend.ts +137 -0
- package/src/sync/sync.ts +6 -89
- package/src/sync/validate-push-payload.ts +6 -7
- 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 {
|
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
|
-
|
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
|
-
|
128
|
+
MaterializeError
|
129
129
|
>
|
130
130
|
|
131
131
|
export type InitialBlockingSyncContext = {
|
132
132
|
blockingDeferred: Deferred.Deferred<void> | undefined
|
133
|
-
update: (_: {
|
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
|
35
|
-
event.decoded === undefined
|
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(
|
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
|
|
package/src/schema/EventDef.ts
CHANGED
@@ -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
|
package/src/schema/schema.ts
CHANGED
@@ -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
|
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
|
-
"{
|
240
|
-
"{
|
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:
|
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:
|
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 {
|
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
|
-
*
|
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
|
-
|
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
|
@@ -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,
|
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: {
|
56
|
-
) =>
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
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)
|
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)
|
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
|
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
|
-
|
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,
|
392
|
-
'2_before_leader_push_queue_clear': Schema.Int.pipe(Schema.between(0,
|
393
|
-
'3_before_rebase_rollback': Schema.Int.pipe(Schema.between(0,
|
394
|
-
'4_before_leader_push_queue_offer': Schema.Int.pipe(Schema.between(0,
|
395
|
-
'5_before_leader_push_fiber_run': Schema.Int.pipe(Schema.between(0,
|
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
|