@livestore/common 0.4.0-dev.21 → 0.4.0-dev.22

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 (93) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +7 -0
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
  4. package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
  5. package/dist/adapter-types.d.ts +23 -0
  6. package/dist/adapter-types.d.ts.map +1 -1
  7. package/dist/adapter-types.js +27 -1
  8. package/dist/adapter-types.js.map +1 -1
  9. package/dist/devtools/devtools-messages-client-session.d.ts +42 -22
  10. package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
  11. package/dist/devtools/devtools-messages-client-session.js +12 -1
  12. package/dist/devtools/devtools-messages-client-session.js.map +1 -1
  13. package/dist/devtools/devtools-messages-common.d.ts +12 -6
  14. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  15. package/dist/devtools/devtools-messages-common.js +7 -2
  16. package/dist/devtools/devtools-messages-common.js.map +1 -1
  17. package/dist/devtools/devtools-messages-leader.d.ts +45 -25
  18. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  19. package/dist/devtools/devtools-messages-leader.js +12 -1
  20. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  21. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  22. package/dist/leader-thread/LeaderSyncProcessor.js +10 -10
  23. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  24. package/dist/leader-thread/leader-worker-devtools.js +9 -0
  25. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  26. package/dist/leader-thread/make-leader-thread-layer.d.ts +4 -2
  27. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  28. package/dist/leader-thread/make-leader-thread-layer.js +5 -1
  29. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  30. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  31. package/dist/leader-thread/materialize-event.js +3 -0
  32. package/dist/leader-thread/materialize-event.js.map +1 -1
  33. package/dist/schema/EventDef/define.d.ts +14 -0
  34. package/dist/schema/EventDef/define.d.ts.map +1 -1
  35. package/dist/schema/EventDef/define.js +1 -0
  36. package/dist/schema/EventDef/define.js.map +1 -1
  37. package/dist/schema/EventDef/deprecated.d.ts +99 -0
  38. package/dist/schema/EventDef/deprecated.d.ts.map +1 -0
  39. package/dist/schema/EventDef/deprecated.js +144 -0
  40. package/dist/schema/EventDef/deprecated.js.map +1 -0
  41. package/dist/schema/EventDef/deprecated.test.d.ts +2 -0
  42. package/dist/schema/EventDef/deprecated.test.d.ts.map +1 -0
  43. package/dist/schema/EventDef/deprecated.test.js +95 -0
  44. package/dist/schema/EventDef/deprecated.test.js.map +1 -0
  45. package/dist/schema/EventDef/event-def.d.ts +4 -0
  46. package/dist/schema/EventDef/event-def.d.ts.map +1 -1
  47. package/dist/schema/EventDef/mod.d.ts +1 -0
  48. package/dist/schema/EventDef/mod.d.ts.map +1 -1
  49. package/dist/schema/EventDef/mod.js +1 -0
  50. package/dist/schema/EventDef/mod.js.map +1 -1
  51. package/dist/schema/LiveStoreEvent/client.d.ts +6 -6
  52. package/dist/schema/state/sqlite/client-document-def.d.ts +1 -0
  53. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  54. package/dist/schema/state/sqlite/client-document-def.js +17 -8
  55. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  56. package/dist/schema/state/sqlite/client-document-def.test.js +120 -1
  57. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  58. package/dist/schema/state/sqlite/query-builder/api.d.ts +27 -11
  59. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  60. package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
  61. package/dist/schema/state/sqlite/query-builder/astToSql.js +71 -1
  62. package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
  63. package/dist/schema/state/sqlite/query-builder/impl.test.js +109 -1
  64. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  65. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  66. package/dist/sync/ClientSessionSyncProcessor.js +6 -2
  67. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  68. package/dist/version.d.ts +7 -1
  69. package/dist/version.d.ts.map +1 -1
  70. package/dist/version.js +8 -1
  71. package/dist/version.js.map +1 -1
  72. package/package.json +4 -4
  73. package/src/ClientSessionLeaderThreadProxy.ts +7 -0
  74. package/src/adapter-types.ts +30 -0
  75. package/src/devtools/devtools-messages-client-session.ts +12 -0
  76. package/src/devtools/devtools-messages-common.ts +7 -3
  77. package/src/devtools/devtools-messages-leader.ts +12 -0
  78. package/src/leader-thread/LeaderSyncProcessor.ts +81 -40
  79. package/src/leader-thread/leader-worker-devtools.ts +11 -0
  80. package/src/leader-thread/make-leader-thread-layer.ts +8 -0
  81. package/src/leader-thread/materialize-event.ts +4 -0
  82. package/src/schema/EventDef/define.ts +16 -0
  83. package/src/schema/EventDef/deprecated.test.ts +128 -0
  84. package/src/schema/EventDef/deprecated.ts +175 -0
  85. package/src/schema/EventDef/event-def.ts +5 -0
  86. package/src/schema/EventDef/mod.ts +1 -0
  87. package/src/schema/state/sqlite/client-document-def.test.ts +140 -2
  88. package/src/schema/state/sqlite/client-document-def.ts +18 -9
  89. package/src/schema/state/sqlite/query-builder/api.ts +25 -3
  90. package/src/schema/state/sqlite/query-builder/astToSql.ts +81 -1
  91. package/src/schema/state/sqlite/query-builder/impl.test.ts +141 -1
  92. package/src/sync/ClientSessionSyncProcessor.ts +26 -13
  93. package/src/version.ts +9 -1
@@ -3,7 +3,13 @@ import { describe, expect, test } from 'vitest'
3
3
 
4
4
  import { tables } from '../../../__tests__/fixture.ts'
5
5
  import type * as LiveStoreEvent from '../../LiveStoreEvent/mod.ts'
6
- import { ClientDocumentTableDefSymbol, clientDocument, mergeDefaultValues } from './client-document-def.ts'
6
+ import {
7
+ ClientDocumentTableDefSymbol,
8
+ clientDocument,
9
+ createOptimisticEventSchema,
10
+ mergeDefaultValues,
11
+ } from './client-document-def.ts'
12
+ import { getResultSchema } from './query-builder/impl.ts'
7
13
 
8
14
  describe('client document table', () => {
9
15
  test('set event', () => {
@@ -248,6 +254,139 @@ describe('client document table', () => {
248
254
  `)
249
255
  })
250
256
  })
257
+
258
+ /** Ensures optimistic decoding stays robust when persisted JSON is incompatible. */
259
+ describe('optimistic schema', () => {
260
+ /** Models persisted JSON using epoch numbers + base64 while app code expects Date + Uint8Array. */
261
+ const valueSchema = Schema.Struct({
262
+ createdAt: Schema.DateFromNumber,
263
+ avatar: Schema.Uint8ArrayFromBase64,
264
+ })
265
+ const defaultValue = {
266
+ createdAt: new Date(0),
267
+ avatar: new Uint8Array(),
268
+ }
269
+ const invalidPayloads: Array<{ label: string; value: unknown }> = [
270
+ { label: 'decoded-shape JSON', value: { createdAt: new Date(0), avatar: new Uint8Array([1, 2]) } },
271
+ { label: 'wrong types', value: { createdAt: 'not-a-number', avatar: { nested: 'bad' } } },
272
+ { label: 'missing required fields', value: {} },
273
+ ]
274
+ const validPayload = { createdAt: 42, avatar: 'AQI=' }
275
+ const extraFieldsPayload = { createdAt: 42, avatar: 'AQI=', extra: 'ignored' }
276
+
277
+ test.each(invalidPayloads)('decodes invalid persisted JSON ($label)', ({ value }) => {
278
+ const optimisticSchema = createOptimisticEventSchema({
279
+ valueSchema,
280
+ defaultValue,
281
+ partialSet: false,
282
+ })
283
+ const rowSchema = Schema.parseJson(optimisticSchema)
284
+
285
+ expect(Schema.decodeUnknownSync(rowSchema)(JSON.stringify(value))).toEqual(defaultValue)
286
+ })
287
+
288
+ test('decodes valid persisted JSON (encoded shape)', () => {
289
+ const optimisticSchema = createOptimisticEventSchema({
290
+ valueSchema,
291
+ defaultValue,
292
+ partialSet: false,
293
+ })
294
+ const rowSchema = Schema.parseJson(optimisticSchema)
295
+
296
+ expect(Schema.decodeUnknownSync(rowSchema)(JSON.stringify(validPayload))).toEqual({
297
+ createdAt: new Date(42),
298
+ avatar: new Uint8Array([1, 2]),
299
+ })
300
+ })
301
+
302
+ test('decodes valid persisted JSON with extra fields', () => {
303
+ const optimisticSchema = createOptimisticEventSchema({
304
+ valueSchema,
305
+ defaultValue,
306
+ partialSet: false,
307
+ })
308
+ const rowSchema = Schema.parseJson(optimisticSchema)
309
+
310
+ expect(Schema.decodeUnknownSync(rowSchema)(JSON.stringify(extraFieldsPayload))).toEqual({
311
+ createdAt: new Date(42),
312
+ avatar: new Uint8Array([1, 2]),
313
+ })
314
+ })
315
+
316
+ test.each(invalidPayloads)('decodes clientDocument rowSchema with invalid JSON ($label)', ({ value }) => {
317
+ const Doc = clientDocument({
318
+ name: 'test_numbers',
319
+ schema: valueSchema,
320
+ default: { value: defaultValue },
321
+ partialSet: false,
322
+ })
323
+ const row = {
324
+ id: 'row-1',
325
+ value: JSON.stringify(value),
326
+ }
327
+
328
+ expect(Schema.decodeUnknownSync(Doc.rowSchema)(row)).toEqual({ id: 'row-1', value: defaultValue })
329
+ })
330
+
331
+ test('decodes clientDocument rowSchema with valid encoded JSON', () => {
332
+ const Doc = clientDocument({
333
+ name: 'test_numbers',
334
+ schema: valueSchema,
335
+ default: { value: defaultValue },
336
+ partialSet: false,
337
+ })
338
+ const row = {
339
+ id: 'row-1',
340
+ value: JSON.stringify(validPayload),
341
+ }
342
+
343
+ expect(Schema.decodeUnknownSync(Doc.rowSchema)(row)).toEqual({
344
+ id: 'row-1',
345
+ value: { createdAt: new Date(42), avatar: new Uint8Array([1, 2]) },
346
+ })
347
+ })
348
+
349
+ test.each(invalidPayloads)('decodes RowQuery result schema with invalid JSON ($label)', ({ value }) => {
350
+ const Doc = clientDocument({
351
+ name: 'test_numbers',
352
+ schema: valueSchema,
353
+ default: { value: defaultValue },
354
+ partialSet: false,
355
+ })
356
+ const query = Doc.get('row-1')
357
+ const resultSchema = getResultSchema(query)
358
+ const rawDbResults = [
359
+ {
360
+ id: 'row-1',
361
+ value: JSON.stringify(value),
362
+ },
363
+ ]
364
+
365
+ expect(Schema.decodeUnknownSync(resultSchema)(rawDbResults)).toEqual(defaultValue)
366
+ })
367
+
368
+ test('decodes RowQuery result schema with valid encoded JSON', () => {
369
+ const Doc = clientDocument({
370
+ name: 'test_numbers',
371
+ schema: valueSchema,
372
+ default: { value: defaultValue },
373
+ partialSet: false,
374
+ })
375
+ const query = Doc.get('row-1')
376
+ const resultSchema = getResultSchema(query)
377
+ const rawDbResults = [
378
+ {
379
+ id: 'row-1',
380
+ value: JSON.stringify(validPayload),
381
+ },
382
+ ]
383
+
384
+ expect(Schema.decodeUnknownSync(resultSchema)(rawDbResults)).toEqual({
385
+ createdAt: new Date(42),
386
+ avatar: new Uint8Array([1, 2]),
387
+ })
388
+ })
389
+ })
251
390
  })
252
391
 
253
392
  const patchId = (muationEvent: LiveStoreEvent.Input.Decoded) => {
@@ -255,7 +394,6 @@ const patchId = (muationEvent: LiveStoreEvent.Input.Decoded) => {
255
394
  const id = `00000000-0000-0000-0000-000000000000`
256
395
  return { ...muationEvent, id }
257
396
  }
258
-
259
397
  describe('mergeDefaultValues', () => {
260
398
  test('merges values from both objects', () => {
261
399
  const defaults = { a: 1, b: 2 }
@@ -98,7 +98,9 @@ export const clientDocument = <
98
98
  value: options.partialSet ? Schema.partial(valueSchema) : valueSchema,
99
99
  }).annotations({ title: `${name}Set:Args` }),
100
100
  })
101
- Object.defineProperty(setEventDef, 'options', { value: { derived: true, clientOnly: true, facts: undefined } })
101
+ Object.defineProperty(setEventDef, 'options', {
102
+ value: { derived: true, clientOnly: true, facts: undefined, deprecated: undefined },
103
+ })
102
104
 
103
105
  const clientDocumentTableDefTrait: ClientDocumentTableDef.Trait<
104
106
  TName,
@@ -176,6 +178,9 @@ export const createOptimisticEventSchema = ({
176
178
  partialSet: boolean
177
179
  }) => {
178
180
  const targetSchema = partialSet ? Schema.partial(valueSchema) : valueSchema
181
+ // The transform decode must yield values in the target schema's ENCODED shape.
182
+ // This keeps JSON columns consistent when Encoded != Type (e.g. Option).
183
+ const encodeTarget = Schema.encodeSync(targetSchema)
179
184
 
180
185
  return Schema.transform(
181
186
  Schema.Unknown, // Accept any historical event structure
@@ -184,14 +189,16 @@ export const createOptimisticEventSchema = ({
184
189
  decode: (eventValue) => {
185
190
  // Try direct decode first (for current schema events)
186
191
  try {
187
- return Schema.decodeUnknownSync(targetSchema)(eventValue)
192
+ const decoded = Schema.decodeUnknownSync(targetSchema)(eventValue)
193
+ // Re-encode so downstream parseJson/column decoding sees encoded values.
194
+ return encodeTarget(decoded)
188
195
  } catch {
189
196
  // Optimistic decoding for historical events
190
197
 
191
198
  // Handle null/undefined/non-object cases
192
199
  if (typeof eventValue !== 'object' || eventValue === null) {
193
200
  console.warn(`Client document: Non-object event value, using ${partialSet ? 'empty partial' : 'defaults'}`)
194
- return partialSet ? {} : defaultValue
201
+ return encodeTarget(partialSet ? {} : defaultValue)
195
202
  }
196
203
 
197
204
  if (partialSet) {
@@ -209,14 +216,15 @@ export const createOptimisticEventSchema = ({
209
216
 
210
217
  if (hasValidFields) {
211
218
  try {
212
- return Schema.decodeUnknownSync(targetSchema)(partialResult)
219
+ const decoded = Schema.decodeUnknownSync(targetSchema)(partialResult)
220
+ return encodeTarget(decoded)
213
221
  } catch {
214
222
  // Even filtered fields don't match schema
215
223
  console.warn('Client document: Partial fields incompatible, returning empty partial')
216
- return {}
224
+ return encodeTarget({})
217
225
  }
218
226
  }
219
- return {}
227
+ return encodeTarget({})
220
228
  } else {
221
229
  // Full set: merge old data with new defaults
222
230
  const merged: Record<string, unknown> = { ...defaultValue }
@@ -231,12 +239,13 @@ export const createOptimisticEventSchema = ({
231
239
 
232
240
  // Try to decode the merged value
233
241
  try {
234
- return Schema.decodeUnknownSync(valueSchema)(merged)
242
+ const decoded = Schema.decodeUnknownSync(valueSchema)(merged)
243
+ return encodeTarget(decoded)
235
244
  } catch {
236
245
  // Merged value still doesn't match (e.g., type changes)
237
246
  // Fall back to pure defaults
238
247
  console.warn('Client document: Could not preserve event data, using defaults')
239
- return defaultValue
248
+ return encodeTarget(defaultValue)
240
249
  }
241
250
  }
242
251
  }
@@ -544,7 +553,7 @@ export namespace ClientDocumentTableDef {
544
553
  readonly name: `${TName}Set`
545
554
  readonly args: { id: string; value: TType }
546
555
  }
547
- readonly options: { derived: true; clientOnly: true; facts: undefined }
556
+ readonly options: { derived: true; clientOnly: true; facts: undefined; deprecated: undefined }
548
557
  }
549
558
 
550
559
  export type SetEventDef<TName extends string, TType, TOptions extends ClientDocumentTableOptions<TType>> = EventDef<
@@ -128,15 +128,25 @@ export type QueryBuilder<
128
128
 
129
129
  export namespace QueryBuilder {
130
130
  export type Any = QueryBuilder<any, any, any>
131
- export type WhereOps = WhereOps.Equality | WhereOps.Order | WhereOps.Like | WhereOps.In
131
+ export type WhereOps = WhereOps.Equality | WhereOps.Order | WhereOps.Like | WhereOps.In | WhereOps.JsonArray
132
132
 
133
133
  export namespace WhereOps {
134
134
  export type Equality = '=' | '!='
135
135
  export type Order = '<' | '>' | '<=' | '>='
136
136
  export type Like = 'LIKE' | 'NOT LIKE' | 'ILIKE' | 'NOT ILIKE'
137
137
  export type In = 'IN' | 'NOT IN'
138
+ /**
139
+ * Operators for checking if a JSON array column contains a value.
140
+ *
141
+ * ⚠️ **Performance note**: These operators use SQLite's `json_each()` table-valued function
142
+ * which **cannot be indexed** and requires a full table scan. For large tables with frequent
143
+ * lookups, consider denormalizing the data into a separate indexed table.
144
+ *
145
+ * @see https://sqlite.org/json1.html#jeach
146
+ */
147
+ export type JsonArray = 'JSON_CONTAINS' | 'JSON_NOT_CONTAINS'
138
148
 
139
- export type SingleValue = Equality | Order | Like
149
+ export type SingleValue = Equality | Order | Like | JsonArray
140
150
  export type MultiValue = In
141
151
  }
142
152
 
@@ -155,14 +165,26 @@ export namespace QueryBuilder {
155
165
  | 'returning'
156
166
  | 'onConflict'
157
167
 
168
+ /** Extracts the element type from an array type, or returns never if not an array */
169
+ type ArrayElement<T> = T extends ReadonlyArray<infer E> ? E : never
170
+
158
171
  export type WhereParams<TTableDef extends TableDefBase> = Partial<{
159
172
  [K in keyof TTableDef['sqliteDef']['columns']]:
160
173
  | TTableDef['sqliteDef']['columns'][K]['schema']['Type']
161
- | { op: QueryBuilder.WhereOps.SingleValue; value: TTableDef['sqliteDef']['columns'][K]['schema']['Type'] }
174
+ | {
175
+ op: Exclude<QueryBuilder.WhereOps.SingleValue, QueryBuilder.WhereOps.JsonArray>
176
+ value: TTableDef['sqliteDef']['columns'][K]['schema']['Type']
177
+ }
162
178
  | {
163
179
  op: QueryBuilder.WhereOps.MultiValue
164
180
  value: ReadonlyArray<TTableDef['sqliteDef']['columns'][K]['schema']['Type']>
165
181
  }
182
+ | (ArrayElement<TTableDef['sqliteDef']['columns'][K]['schema']['Type']> extends never
183
+ ? never
184
+ : {
185
+ op: QueryBuilder.WhereOps.JsonArray
186
+ value: ArrayElement<TTableDef['sqliteDef']['columns'][K]['schema']['Type']>
187
+ })
166
188
  | undefined
167
189
  }>
168
190
 
@@ -1,11 +1,74 @@
1
1
  import { shouldNeverHappen } from '@livestore/utils'
2
- import { Schema } from '@livestore/utils/effect'
2
+ import { Schema, SchemaAST } from '@livestore/utils/effect'
3
3
 
4
4
  import { SessionIdSymbol } from '../../../../adapter-types.ts'
5
5
  import type { SqlValue } from '../../../../util.ts'
6
6
  import type { State } from '../../../mod.ts'
7
7
  import type { QueryBuilderAst } from './api.ts'
8
8
 
9
+ /**
10
+ * Extracts array element schema from a JSON array transformation AST.
11
+ * Returns the element schema, or undefined if not a JSON array transformation.
12
+ */
13
+ const extractArrayElementFromTransformation = (ast: SchemaAST.AST): Schema.Schema.Any | undefined => {
14
+ if (!SchemaAST.isTransformation(ast)) return undefined
15
+
16
+ const toAst = ast.to
17
+ // Check if the "to" side is a TupleType (Effect's internal representation of Array)
18
+ if (!SchemaAST.isTupleType(toAst)) return undefined
19
+
20
+ // For Schema.Array, rest contains { type: AST } elements - get the first one's type
21
+ const restElement = toAst.rest[0]
22
+ if (restElement === undefined) return undefined
23
+
24
+ return Schema.make(restElement.type)
25
+ }
26
+
27
+ /**
28
+ * For JSON array columns, extracts the element schema from Schema.parseJson(Schema.Array(ElementSchema)).
29
+ * Also handles nullable JSON arrays (Schema.NullOr(Schema.parseJson(Schema.Array(...)))).
30
+ * Returns the element schema, or undefined if the column is not a JSON array.
31
+ */
32
+ const getJsonArrayElementSchema = (colSchema: Schema.Schema.Any): Schema.Schema.Any | undefined => {
33
+ const ast = colSchema.ast
34
+
35
+ // Case 1: Direct transformation (non-nullable JSON array)
36
+ // Schema.parseJson(Schema.Array(ElementSchema)) creates a Transformation AST
37
+ if (SchemaAST.isTransformation(ast)) {
38
+ return extractArrayElementFromTransformation(ast)
39
+ }
40
+
41
+ // Case 2: Nullable JSON array - Schema.NullOr wraps the parseJson in a Union
42
+ // Structure: Union([Transformation (JSON array), Literal (null)])
43
+ if (SchemaAST.isUnion(ast)) {
44
+ for (const member of ast.types) {
45
+ const result = extractArrayElementFromTransformation(member)
46
+ if (result !== undefined) return result
47
+ }
48
+ }
49
+
50
+ return undefined
51
+ }
52
+
53
+ /**
54
+ * Encodes a JSON array element to the representation returned by SQLite's json_each().
55
+ * Objects/arrays are stringified so they match json_each's TEXT representation.
56
+ */
57
+ const encodeJsonArrayElementValue = (elementSchema: Schema.Schema.Any, value: unknown): SqlValue => {
58
+ const encoded = Schema.encodeSync(elementSchema as Schema.Schema<unknown, SqlValue>)(value)
59
+
60
+ if (encoded === null) return null
61
+ if (typeof encoded === 'object') {
62
+ // Objects and arrays need to be JSON-stringified to match json_each() output
63
+ return JSON.stringify(encoded)
64
+ }
65
+ if (typeof encoded === 'boolean') {
66
+ return encoded ? 1 : 0
67
+ }
68
+
69
+ return encoded
70
+ }
71
+
9
72
  // Helper functions for SQL generation
10
73
  const quoteIdentifier = (identifier: string): string => `"${identifier.replace(/"/g, '""')}"`
11
74
 
@@ -35,6 +98,23 @@ const formatWhereClause = (
35
98
  throw new Error(`Column ${col} not found`)
36
99
  }
37
100
 
101
+ // Handle JSON array containment operators
102
+ if (op === 'JSON_CONTAINS' || op === 'JSON_NOT_CONTAINS') {
103
+ const elementSchema = getJsonArrayElementSchema(colDef.schema)
104
+ if (elementSchema === undefined) {
105
+ throw new Error(
106
+ `${op} operator can only be used on JSON array columns, but column "${col}" is not a JSON array`,
107
+ )
108
+ }
109
+
110
+ const existsOp = op === 'JSON_CONTAINS' ? 'EXISTS' : 'NOT EXISTS'
111
+ // Encode the element value using the element schema
112
+ // Objects are JSON-stringified to match json_each() output
113
+ const encodedValue = encodeJsonArrayElementValue(elementSchema, value)
114
+ bindValues.push(encodedValue)
115
+ return `${existsOp} (SELECT 1 FROM json_each(${quotedCol}) WHERE value = ?)`
116
+ }
117
+
38
118
  // Handle array values for IN/NOT IN operators
39
119
  const isArray = op === 'IN' || op === 'NOT IN'
40
120
 
@@ -81,7 +81,22 @@ const selections = State.SQLite.table({
81
81
  },
82
82
  })
83
83
 
84
- const db = { todos, todosWithIntId, comments, issue, selections, UiState, UiStateWithDefaultId }
84
+ const Source = Schema.Literal('google', 'linkedin', 'facebook')
85
+ const ProfileAttribute = Schema.Struct({ key: Schema.String, value: Schema.String })
86
+
87
+ const personProfiles = State.SQLite.table({
88
+ name: 'person_profiles',
89
+ columns: {
90
+ personId: State.SQLite.text({ primaryKey: true }),
91
+ sources: State.SQLite.json({ schema: Schema.Array(Source), default: [] }),
92
+ tags: State.SQLite.json({ schema: Schema.Array(Schema.String), default: [] }),
93
+ attributes: State.SQLite.json({ schema: Schema.Array(ProfileAttribute), default: [] }),
94
+ /** Nullable JSON array column for testing JSON_CONTAINS on nullable columns */
95
+ optionalTags: State.SQLite.json({ schema: Schema.Array(Schema.String), nullable: true }),
96
+ },
97
+ })
98
+
99
+ const db = { todos, todosWithIntId, comments, issue, selections, UiState, UiStateWithDefaultId, personProfiles }
85
100
 
86
101
  const dump = (qb: QueryBuilder<any, any, any>) => ({
87
102
  bindValues: qb.asSql().bindValues,
@@ -364,6 +379,131 @@ describe('query builder', () => {
364
379
  }
365
380
  `)
366
381
  })
382
+
383
+ it('should handle JSON_CONTAINS operator for JSON array columns', () => {
384
+ expect(
385
+ dump(db.personProfiles.where({ sources: { op: 'JSON_CONTAINS', value: 'google' } })),
386
+ ).toMatchInlineSnapshot(`
387
+ {
388
+ "bindValues": [
389
+ "google",
390
+ ],
391
+ "query": "SELECT * FROM 'person_profiles' WHERE EXISTS (SELECT 1 FROM json_each("sources") WHERE value = ?)",
392
+ "schema": "ReadonlyArray<person_profiles>",
393
+ }
394
+ `)
395
+
396
+ // With select
397
+ expect(
398
+ dump(db.personProfiles.select('personId').where({ sources: { op: 'JSON_CONTAINS', value: 'linkedin' } })),
399
+ ).toMatchInlineSnapshot(`
400
+ {
401
+ "bindValues": [
402
+ "linkedin",
403
+ ],
404
+ "query": "SELECT "personId" FROM 'person_profiles' WHERE EXISTS (SELECT 1 FROM json_each("sources") WHERE value = ?)",
405
+ "schema": "ReadonlyArray<({ readonly personId: string } <-> string)>",
406
+ }
407
+ `)
408
+
409
+ // With plain string array column
410
+ expect(
411
+ dump(db.personProfiles.where({ tags: { op: 'JSON_CONTAINS', value: 'important' } })),
412
+ ).toMatchInlineSnapshot(`
413
+ {
414
+ "bindValues": [
415
+ "important",
416
+ ],
417
+ "query": "SELECT * FROM 'person_profiles' WHERE EXISTS (SELECT 1 FROM json_each("tags") WHERE value = ?)",
418
+ "schema": "ReadonlyArray<person_profiles>",
419
+ }
420
+ `)
421
+ })
422
+
423
+ it('should handle JSON_NOT_CONTAINS operator for JSON array columns', () => {
424
+ expect(
425
+ dump(db.personProfiles.where({ sources: { op: 'JSON_NOT_CONTAINS', value: 'google' } })),
426
+ ).toMatchInlineSnapshot(`
427
+ {
428
+ "bindValues": [
429
+ "google",
430
+ ],
431
+ "query": "SELECT * FROM 'person_profiles' WHERE NOT EXISTS (SELECT 1 FROM json_each("sources") WHERE value = ?)",
432
+ "schema": "ReadonlyArray<person_profiles>",
433
+ }
434
+ `)
435
+ })
436
+
437
+ it('should JSON-stringify object elements for JSON_CONTAINS', () => {
438
+ expect(
439
+ dump(
440
+ db.personProfiles.where({
441
+ attributes: { op: 'JSON_CONTAINS', value: { key: 'language', value: 'typescript' } },
442
+ }),
443
+ ),
444
+ ).toMatchInlineSnapshot(`
445
+ {
446
+ "bindValues": [
447
+ "{"key":"language","value":"typescript"}",
448
+ ],
449
+ "query": "SELECT * FROM 'person_profiles' WHERE EXISTS (SELECT 1 FROM json_each("attributes") WHERE value = ?)",
450
+ "schema": "ReadonlyArray<person_profiles>",
451
+ }
452
+ `)
453
+ })
454
+
455
+ it('should handle combining JSON_CONTAINS with other WHERE clauses', () => {
456
+ expect(
457
+ dump(
458
+ db.personProfiles
459
+ .where({ sources: { op: 'JSON_CONTAINS', value: 'google' } })
460
+ .where({ sources: { op: 'JSON_NOT_CONTAINS', value: 'facebook' } }),
461
+ ),
462
+ ).toMatchInlineSnapshot(`
463
+ {
464
+ "bindValues": [
465
+ "google",
466
+ "facebook",
467
+ ],
468
+ "query": "SELECT * FROM 'person_profiles' WHERE EXISTS (SELECT 1 FROM json_each("sources") WHERE value = ?) AND NOT EXISTS (SELECT 1 FROM json_each("sources") WHERE value = ?)",
469
+ "schema": "ReadonlyArray<person_profiles>",
470
+ }
471
+ `)
472
+ })
473
+
474
+ it('should handle JSON_CONTAINS on nullable JSON array columns', () => {
475
+ expect(
476
+ dump(db.personProfiles.where({ optionalTags: { op: 'JSON_CONTAINS', value: 'important' } })),
477
+ ).toMatchInlineSnapshot(`
478
+ {
479
+ "bindValues": [
480
+ "important",
481
+ ],
482
+ "query": "SELECT * FROM 'person_profiles' WHERE EXISTS (SELECT 1 FROM json_each("optionalTags") WHERE value = ?)",
483
+ "schema": "ReadonlyArray<person_profiles>",
484
+ }
485
+ `)
486
+
487
+ // With JSON_NOT_CONTAINS
488
+ expect(
489
+ dump(db.personProfiles.where({ optionalTags: { op: 'JSON_NOT_CONTAINS', value: 'archived' } })),
490
+ ).toMatchInlineSnapshot(`
491
+ {
492
+ "bindValues": [
493
+ "archived",
494
+ ],
495
+ "query": "SELECT * FROM 'person_profiles' WHERE NOT EXISTS (SELECT 1 FROM json_each("optionalTags") WHERE value = ?)",
496
+ "schema": "ReadonlyArray<person_profiles>",
497
+ }
498
+ `)
499
+ })
500
+
501
+ it('should throw error when using JSON_CONTAINS on non-JSON array column', () => {
502
+ expect(() =>
503
+ // Type system prevents this at compile time for non-array columns, but test runtime check
504
+ dump(db.todos.where({ status: { op: 'JSON_CONTAINS', value: 'active' } } as any)),
505
+ ).toThrow('JSON_CONTAINS operator can only be used on JSON array columns')
506
+ })
367
507
  })
368
508
 
369
509
  // describe('getOrCreate queries', () => {
@@ -22,6 +22,11 @@ import * as LiveStoreEvent from '../schema/LiveStoreEvent/mod.ts'
22
22
  import type { LiveStoreSchema } from '../schema/mod.ts'
23
23
  import * as SyncState from './syncstate.ts'
24
24
 
25
+ // WORKAROUND: @effect/opentelemetry mis-parses `Span.addEvent(name, attributes)` and treats the attributes object as a
26
+ // time input, causing `TypeError: {} is not iterable` at runtime.
27
+ // Upstream: https://github.com/Effect-TS/effect/pull/5929
28
+ // TODO: simplify back to the 2-arg overload once the upstream fix is released and adopted.
29
+
25
30
  /**
26
31
  * Rebase behaviour:
27
32
  * - We continously pull events from the leader and apply them to the local store.
@@ -245,13 +250,17 @@ export const makeClientSessionSyncProcessor = ({
245
250
  syncStateRef.current = mergeResult.newSyncState
246
251
 
247
252
  if (mergeResult._tag === 'rebase') {
248
- span.addEvent('merge:pull:rebase', {
249
- payloadTag: payload._tag,
250
- payload: TRACE_VERBOSE ? JSON.stringify(payload) : undefined,
251
- newEventsCount: mergeResult.newEvents.length,
252
- rollbackCount: mergeResult.rollbackEvents.length,
253
- res: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
254
- })
253
+ span.addEvent(
254
+ 'merge:pull:rebase',
255
+ {
256
+ payloadTag: payload._tag,
257
+ payload: TRACE_VERBOSE ? JSON.stringify(payload) : undefined,
258
+ newEventsCount: mergeResult.newEvents.length,
259
+ rollbackCount: mergeResult.rollbackEvents.length,
260
+ res: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
261
+ },
262
+ undefined,
263
+ )
255
264
 
256
265
  debugInfo.rebaseCount++
257
266
 
@@ -290,12 +299,16 @@ export const makeClientSessionSyncProcessor = ({
290
299
 
291
300
  yield* FiberHandle.run(leaderPushingFiberHandle, backgroundLeaderPushing)
292
301
  } else {
293
- span.addEvent('merge:pull:advance', {
294
- payloadTag: payload._tag,
295
- payload: TRACE_VERBOSE ? JSON.stringify(payload) : undefined,
296
- newEventsCount: mergeResult.newEvents.length,
297
- res: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
298
- })
302
+ span.addEvent(
303
+ 'merge:pull:advance',
304
+ {
305
+ payloadTag: payload._tag,
306
+ payload: TRACE_VERBOSE ? JSON.stringify(payload) : undefined,
307
+ newEventsCount: mergeResult.newEvents.length,
308
+ res: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
309
+ },
310
+ undefined,
311
+ )
299
312
 
300
313
  debugInfo.advanceCount++
301
314
  }
package/src/version.ts CHANGED
@@ -2,7 +2,15 @@
2
2
  // import packageJson from '../package.json' with { type: 'json' }
3
3
  // export const liveStoreVersion = packageJson.version
4
4
 
5
- export const liveStoreVersion = '0.4.0-dev.21' as const
5
+ const _liveStoreVersion = '0.4.0-dev.22' as const
6
+
7
+ /**
8
+ * Current LiveStore version used for DevTools version compatibility checks.
9
+ *
10
+ * Can be overridden at runtime via `globalThis.__LIVESTORE_VERSION_OVERRIDE__` for testing purposes.
11
+ * This allows Playwright tests to simulate version mismatch scenarios without rebuilding.
12
+ */
13
+ export const liveStoreVersion: string = (globalThis as any).__LIVESTORE_VERSION_OVERRIDE__ ?? _liveStoreVersion
6
14
 
7
15
  /**
8
16
  * CRITICAL: Increment this version whenever you modify client-side EVENTLOG table schemas.