@livestore/common 0.4.0-dev.2 → 0.4.0-dev.5
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 +15 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -2
- 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 +34 -15
- 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.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 +35 -5
- 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 +16 -0
- 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 +21 -3
- package/dist/schema/state/sqlite/db-schema/ast/sqlite.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/api.d.ts +5 -2
- package/dist/schema/state/sqlite/query-builder/api.d.ts.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 +54 -0
- package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
- package/dist/schema/state/sqlite/system-tables.d.ts +36 -0
- 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/sync/ClientSessionSyncProcessor.d.ts +6 -9
- package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.js +17 -17
- 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 +14 -3
- 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 +64 -22
- 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 +17 -0
- package/src/schema/state/sqlite/client-document-def.ts +115 -3
- package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +24 -3
- package/src/schema/state/sqlite/mod.ts +1 -0
- package/src/schema/state/sqlite/query-builder/api.ts +7 -2
- package/src/schema/state/sqlite/query-builder/impl.test.ts +64 -0
- package/src/schema/state/sqlite/query-builder/impl.ts +8 -2
- package/src/schema/state/sqlite/system-tables.ts +2 -0
- package/src/sync/ClientSessionSyncProcessor.ts +32 -32
- 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
@@ -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
|
|
@@ -230,6 +231,22 @@ describe('client document table', () => {
|
|
230
231
|
}
|
231
232
|
`)
|
232
233
|
})
|
234
|
+
|
235
|
+
test('any value (Schema.Any) should fully replace', () => {
|
236
|
+
expect(forSchema(Schema.Any, { a: 1 }, 'id1')).toMatchInlineSnapshot(`
|
237
|
+
{
|
238
|
+
"bindValues": [
|
239
|
+
"id1",
|
240
|
+
"{"a":1}",
|
241
|
+
"{"a":1}",
|
242
|
+
],
|
243
|
+
"sql": "INSERT INTO 'test' (id, value) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET value = ?",
|
244
|
+
"writeTables": Set {
|
245
|
+
"test",
|
246
|
+
},
|
247
|
+
}
|
248
|
+
`)
|
249
|
+
})
|
233
250
|
})
|
234
251
|
})
|
235
252
|
|
@@ -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,
|
@@ -281,8 +387,14 @@ export namespace ClientDocumentTableOptions {
|
|
281
387
|
}
|
282
388
|
}
|
283
389
|
|
390
|
+
type IsStructLike<T> = T extends {} ? true : false
|
391
|
+
|
284
392
|
export type WithDefaults<TInput extends Input<any>> = {
|
285
|
-
partialSet: TInput['partialSet'] extends false
|
393
|
+
partialSet: TInput['partialSet'] extends false
|
394
|
+
? false
|
395
|
+
: IsStructLike<TInput['default']['value']> extends true
|
396
|
+
? true
|
397
|
+
: false
|
286
398
|
default: {
|
287
399
|
id: TInput['default']['id'] extends string | SessionIdSymbol ? TInput['default']['id'] : undefined
|
288
400
|
value: TInput['default']['value']
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import { type Option, Schema } from '@livestore/utils/effect'
|
1
|
+
import { type Option, Schema, SchemaAST } from '@livestore/utils/effect'
|
2
2
|
|
3
3
|
import { hashCode } from '../hash.ts'
|
4
4
|
|
@@ -85,7 +85,19 @@ export type DbSchema = {
|
|
85
85
|
export const dbSchema = (tables: Table[]): DbSchema => ({ _tag: 'dbSchema', tables })
|
86
86
|
|
87
87
|
/**
|
88
|
-
*
|
88
|
+
* Helper to detect if a column is a JSON column (has parseJson transformation)
|
89
|
+
*/
|
90
|
+
const isJsonColumn = (column: Column): boolean => {
|
91
|
+
if (column.type._tag !== 'text') return false
|
92
|
+
|
93
|
+
// Check if the schema AST is a parseJson transformation
|
94
|
+
const ast = column.schema.ast
|
95
|
+
return ast._tag === 'Transformation' && ast.annotations.schemaId === SchemaAST.ParseJsonSchemaId
|
96
|
+
}
|
97
|
+
|
98
|
+
/**
|
99
|
+
* NOTE we're now including JSON schema information for JSON columns
|
100
|
+
* to detect client document schema changes
|
89
101
|
*/
|
90
102
|
export const hash = (obj: Table | Column | Index | ForeignKey | DbSchema): number =>
|
91
103
|
hashCode(JSON.stringify(trimInfoForHasing(obj)))
|
@@ -101,7 +113,7 @@ const trimInfoForHasing = (obj: Table | Column | Index | ForeignKey | DbSchema):
|
|
101
113
|
}
|
102
114
|
}
|
103
115
|
case 'column': {
|
104
|
-
|
116
|
+
const baseInfo: Record<string, any> = {
|
105
117
|
_tag: 'column',
|
106
118
|
name: obj.name,
|
107
119
|
type: obj.type._tag,
|
@@ -110,6 +122,15 @@ const trimInfoForHasing = (obj: Table | Column | Index | ForeignKey | DbSchema):
|
|
110
122
|
autoIncrement: obj.autoIncrement,
|
111
123
|
default: obj.default,
|
112
124
|
}
|
125
|
+
|
126
|
+
// NEW: Include schema hash for JSON columns
|
127
|
+
// This ensures that changes to the JSON schema are detected
|
128
|
+
if (isJsonColumn(obj) && obj.schema) {
|
129
|
+
// Use Effect's Schema.hash for consistent hashing
|
130
|
+
baseInfo.jsonSchemaHash = Schema.hash(obj.schema)
|
131
|
+
}
|
132
|
+
|
133
|
+
return baseInfo
|
113
134
|
}
|
114
135
|
case 'index': {
|
115
136
|
return {
|
@@ -3,7 +3,7 @@ import { type Option, Predicate, type Schema } from '@livestore/utils/effect'
|
|
3
3
|
|
4
4
|
import type { SessionIdSymbol } from '../../../../adapter-types.ts'
|
5
5
|
import type { SqlValue } from '../../../../util.ts'
|
6
|
-
import type { ClientDocumentTableDef } from '../client-document-def.ts'
|
6
|
+
import type { ClientDocumentTableDef, ClientDocumentTableDefSymbol } from '../client-document-def.ts'
|
7
7
|
import type { SqliteDsl } from '../db-schema/mod.ts'
|
8
8
|
import type { TableDefBase } from '../table-def.ts'
|
9
9
|
|
@@ -437,7 +437,12 @@ export namespace QueryBuilder {
|
|
437
437
|
|
438
438
|
export namespace RowQuery {
|
439
439
|
export type GetOrCreateOptions<TTableDef extends ClientDocumentTableDef.TraitAny> = {
|
440
|
-
|
440
|
+
/**
|
441
|
+
* Default value to use instead of the default value from the table definition
|
442
|
+
*/
|
443
|
+
default: TTableDef[ClientDocumentTableDefSymbol]['options']['partialSet'] extends false
|
444
|
+
? TTableDef['Value']
|
445
|
+
: Partial<TTableDef['Value']>
|
441
446
|
}
|
442
447
|
|
443
448
|
// TODO get rid of this
|
@@ -622,6 +622,70 @@ describe('query builder', () => {
|
|
622
622
|
}
|
623
623
|
`)
|
624
624
|
})
|
625
|
+
|
626
|
+
it('should handle where().delete() - preserving where clauses', () => {
|
627
|
+
expect(dump(db.todos.where({ status: 'completed' }).delete())).toMatchInlineSnapshot(`
|
628
|
+
{
|
629
|
+
"bindValues": [
|
630
|
+
"completed",
|
631
|
+
],
|
632
|
+
"query": "DELETE FROM 'todos' WHERE status = ?",
|
633
|
+
"schema": "number",
|
634
|
+
}
|
635
|
+
`)
|
636
|
+
|
637
|
+
// Multiple where clauses
|
638
|
+
expect(dump(db.todos.where({ status: 'completed' }).where({ deletedAt: null }).delete())).toMatchInlineSnapshot(`
|
639
|
+
{
|
640
|
+
"bindValues": [
|
641
|
+
"completed",
|
642
|
+
],
|
643
|
+
"query": "DELETE FROM 'todos' WHERE status = ? AND deletedAt IS NULL",
|
644
|
+
"schema": "number",
|
645
|
+
}
|
646
|
+
`)
|
647
|
+
})
|
648
|
+
|
649
|
+
it('should handle where().update() - preserving where clauses', () => {
|
650
|
+
expect(dump(db.todos.where({ id: '123' }).update({ status: 'completed' }))).toMatchInlineSnapshot(`
|
651
|
+
{
|
652
|
+
"bindValues": [
|
653
|
+
"completed",
|
654
|
+
"123",
|
655
|
+
],
|
656
|
+
"query": "UPDATE 'todos' SET status = ? WHERE id = ?",
|
657
|
+
"schema": "number",
|
658
|
+
}
|
659
|
+
`)
|
660
|
+
|
661
|
+
// Multiple where clauses
|
662
|
+
expect(
|
663
|
+
dump(db.todos.where({ id: '123' }).where({ deletedAt: null }).update({ status: 'completed' })),
|
664
|
+
).toMatchInlineSnapshot(`
|
665
|
+
{
|
666
|
+
"bindValues": [
|
667
|
+
"completed",
|
668
|
+
"123",
|
669
|
+
],
|
670
|
+
"query": "UPDATE 'todos' SET status = ? WHERE id = ? AND deletedAt IS NULL",
|
671
|
+
"schema": "number",
|
672
|
+
}
|
673
|
+
`)
|
674
|
+
})
|
675
|
+
|
676
|
+
it('should have equivalent behavior for both delete patterns', () => {
|
677
|
+
const pattern1 = dump(db.todos.where({ status: 'completed', id: '123' }).delete())
|
678
|
+
const pattern2 = dump(db.todos.delete().where({ status: 'completed', id: '123' }))
|
679
|
+
|
680
|
+
expect(pattern1).toEqual(pattern2)
|
681
|
+
})
|
682
|
+
|
683
|
+
it('should have equivalent behavior for both update patterns', () => {
|
684
|
+
const pattern1 = dump(db.todos.where({ id: '123' }).update({ status: 'completed', text: 'Updated' }))
|
685
|
+
const pattern2 = dump(db.todos.update({ status: 'completed', text: 'Updated' }).where({ id: '123' }))
|
686
|
+
|
687
|
+
expect(pattern1).toEqual(pattern2)
|
688
|
+
})
|
625
689
|
})
|
626
690
|
})
|
627
691
|
|
@@ -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
|
|
@@ -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.
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import { Schema } from '@livestore/utils/effect'
|
2
|
+
import { UnexpectedError } from '../errors.ts'
|
3
|
+
import { EventSequenceNumber } from '../schema/mod.ts'
|
4
|
+
|
5
|
+
export class IsOfflineError extends Schema.TaggedError<IsOfflineError>()('IsOfflineError', {
|
6
|
+
cause: Schema.Defect,
|
7
|
+
}) {}
|
8
|
+
|
9
|
+
/** Unique ID generated by the backend when its created. Used to check whether the backend identity has changed. */
|
10
|
+
export const BackendId = Schema.String.annotations({ title: '@livestore/sync-cf:BackendId' })
|
11
|
+
|
12
|
+
export class BackendIdMismatchError extends Schema.TaggedError<BackendIdMismatchError>()('BackendIdMismatchError', {
|
13
|
+
expected: BackendId,
|
14
|
+
received: BackendId,
|
15
|
+
}) {}
|
16
|
+
|
17
|
+
export class ServerAheadError extends Schema.TaggedError<ServerAheadError>()('ServerAheadError', {
|
18
|
+
minimumExpectedNum: EventSequenceNumber.GlobalEventSequenceNumber,
|
19
|
+
providedNum: EventSequenceNumber.GlobalEventSequenceNumber,
|
20
|
+
}) {}
|
21
|
+
|
22
|
+
export class InvalidPushError extends Schema.TaggedError<InvalidPushError>()('InvalidPushError', {
|
23
|
+
cause: Schema.Union(UnexpectedError, ServerAheadError, BackendIdMismatchError),
|
24
|
+
}) {}
|
25
|
+
|
26
|
+
export class InvalidPullError extends Schema.TaggedError<InvalidPullError>()('InvalidPullError', {
|
27
|
+
cause: Schema.Defect,
|
28
|
+
}) {}
|
29
|
+
|
30
|
+
export class LeaderAheadError extends Schema.TaggedError<LeaderAheadError>()('LeaderAheadError', {
|
31
|
+
minimumExpectedNum: EventSequenceNumber.EventSequenceNumber,
|
32
|
+
providedNum: EventSequenceNumber.EventSequenceNumber,
|
33
|
+
/** Generation number the client session should use for subsequent pushes */
|
34
|
+
// nextGeneration: Schema.Number,
|
35
|
+
}) {}
|
36
|
+
|
37
|
+
export const SyncError = Schema.Union(InvalidPushError, InvalidPullError)
|
38
|
+
export type SyncError = typeof SyncError.Type
|
package/src/sync/index.ts
CHANGED