@livestore/common 0.4.0-dev.20 → 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 (127) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +10 -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 +47 -25
  18. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  19. package/dist/devtools/devtools-messages-leader.js +13 -1
  20. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  21. package/dist/leader-thread/LeaderSyncProcessor.d.ts +33 -0
  22. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  23. package/dist/leader-thread/LeaderSyncProcessor.js +12 -12
  24. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  25. package/dist/leader-thread/eventlog.d.ts +6 -1
  26. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  27. package/dist/leader-thread/eventlog.js +59 -2
  28. package/dist/leader-thread/eventlog.js.map +1 -1
  29. package/dist/leader-thread/leader-worker-devtools.js +38 -6
  30. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  31. package/dist/leader-thread/make-leader-thread-layer.d.ts +4 -2
  32. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  33. package/dist/leader-thread/make-leader-thread-layer.js +5 -1
  34. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  35. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  36. package/dist/leader-thread/materialize-event.js +3 -0
  37. package/dist/leader-thread/materialize-event.js.map +1 -1
  38. package/dist/leader-thread/mod.d.ts +1 -0
  39. package/dist/leader-thread/mod.d.ts.map +1 -1
  40. package/dist/leader-thread/mod.js +1 -0
  41. package/dist/leader-thread/mod.js.map +1 -1
  42. package/dist/leader-thread/stream-events.d.ts +56 -0
  43. package/dist/leader-thread/stream-events.d.ts.map +1 -0
  44. package/dist/leader-thread/stream-events.js +166 -0
  45. package/dist/leader-thread/stream-events.js.map +1 -0
  46. package/dist/leader-thread/types.d.ts +77 -1
  47. package/dist/leader-thread/types.d.ts.map +1 -1
  48. package/dist/leader-thread/types.js +13 -0
  49. package/dist/leader-thread/types.js.map +1 -1
  50. package/dist/otel.d.ts +2 -1
  51. package/dist/otel.d.ts.map +1 -1
  52. package/dist/otel.js +5 -0
  53. package/dist/otel.js.map +1 -1
  54. package/dist/schema/EventDef/define.d.ts +14 -0
  55. package/dist/schema/EventDef/define.d.ts.map +1 -1
  56. package/dist/schema/EventDef/define.js +1 -0
  57. package/dist/schema/EventDef/define.js.map +1 -1
  58. package/dist/schema/EventDef/deprecated.d.ts +99 -0
  59. package/dist/schema/EventDef/deprecated.d.ts.map +1 -0
  60. package/dist/schema/EventDef/deprecated.js +144 -0
  61. package/dist/schema/EventDef/deprecated.js.map +1 -0
  62. package/dist/schema/EventDef/deprecated.test.d.ts +2 -0
  63. package/dist/schema/EventDef/deprecated.test.d.ts.map +1 -0
  64. package/dist/schema/EventDef/deprecated.test.js +95 -0
  65. package/dist/schema/EventDef/deprecated.test.js.map +1 -0
  66. package/dist/schema/EventDef/event-def.d.ts +4 -0
  67. package/dist/schema/EventDef/event-def.d.ts.map +1 -1
  68. package/dist/schema/EventDef/mod.d.ts +1 -0
  69. package/dist/schema/EventDef/mod.d.ts.map +1 -1
  70. package/dist/schema/EventDef/mod.js +1 -0
  71. package/dist/schema/EventDef/mod.js.map +1 -1
  72. package/dist/schema/LiveStoreEvent/client.d.ts +6 -6
  73. package/dist/schema/state/sqlite/client-document-def.d.ts +1 -0
  74. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  75. package/dist/schema/state/sqlite/client-document-def.js +17 -8
  76. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  77. package/dist/schema/state/sqlite/client-document-def.test.js +120 -1
  78. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  79. package/dist/schema/state/sqlite/column-def.test.js +2 -3
  80. package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
  81. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  82. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  83. package/dist/schema/state/sqlite/query-builder/api.d.ts +29 -12
  84. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  85. package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
  86. package/dist/schema/state/sqlite/query-builder/astToSql.js +71 -1
  87. package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
  88. package/dist/schema/state/sqlite/query-builder/impl.test.js +109 -1
  89. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  90. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  91. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  92. package/dist/sync/ClientSessionSyncProcessor.js +6 -2
  93. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  94. package/dist/version.d.ts +7 -1
  95. package/dist/version.d.ts.map +1 -1
  96. package/dist/version.js +8 -1
  97. package/dist/version.js.map +1 -1
  98. package/package.json +4 -4
  99. package/src/ClientSessionLeaderThreadProxy.ts +10 -0
  100. package/src/adapter-types.ts +30 -0
  101. package/src/devtools/devtools-messages-client-session.ts +12 -0
  102. package/src/devtools/devtools-messages-common.ts +7 -3
  103. package/src/devtools/devtools-messages-leader.ts +13 -0
  104. package/src/leader-thread/LeaderSyncProcessor.ts +116 -42
  105. package/src/leader-thread/eventlog.ts +80 -4
  106. package/src/leader-thread/leader-worker-devtools.ts +52 -6
  107. package/src/leader-thread/make-leader-thread-layer.ts +8 -0
  108. package/src/leader-thread/materialize-event.ts +4 -0
  109. package/src/leader-thread/mod.ts +1 -0
  110. package/src/leader-thread/stream-events.ts +201 -0
  111. package/src/leader-thread/types.ts +49 -1
  112. package/src/otel.ts +10 -0
  113. package/src/schema/EventDef/define.ts +16 -0
  114. package/src/schema/EventDef/deprecated.test.ts +128 -0
  115. package/src/schema/EventDef/deprecated.ts +175 -0
  116. package/src/schema/EventDef/event-def.ts +5 -0
  117. package/src/schema/EventDef/mod.ts +1 -0
  118. package/src/schema/state/sqlite/client-document-def.test.ts +140 -2
  119. package/src/schema/state/sqlite/client-document-def.ts +25 -26
  120. package/src/schema/state/sqlite/column-def.test.ts +2 -3
  121. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +10 -16
  122. package/src/schema/state/sqlite/query-builder/api.ts +31 -4
  123. package/src/schema/state/sqlite/query-builder/astToSql.ts +81 -1
  124. package/src/schema/state/sqlite/query-builder/impl.test.ts +141 -1
  125. package/src/schema/state/sqlite/table-def.ts +9 -8
  126. package/src/sync/ClientSessionSyncProcessor.ts +26 -13
  127. 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
  }
@@ -513,27 +522,17 @@ export namespace ClientDocumentTableDef {
513
522
  }
514
523
  }
515
524
 
516
- export type GetOptions<TTableDef extends TraitAny> = TTableDef extends ClientDocumentTableDef.Trait<
517
- any,
518
- any,
519
- any,
520
- infer TOptions
521
- >
522
- ? TOptions
523
- : never
525
+ export type GetOptions<TTableDef extends TraitAny> =
526
+ TTableDef extends ClientDocumentTableDef.Trait<any, any, any, infer TOptions> ? TOptions : never
524
527
 
525
528
  export type TraitAny = Trait<any, any, any, any>
526
529
 
527
- export type DefaultIdType<TTableDef extends TraitAny> = TTableDef extends ClientDocumentTableDef.Trait<
528
- any,
529
- any,
530
- any,
531
- infer TOptions
532
- >
533
- ? TOptions['default']['id'] extends SessionIdSymbol | string
534
- ? TOptions['default']['id']
530
+ export type DefaultIdType<TTableDef extends TraitAny> =
531
+ TTableDef extends ClientDocumentTableDef.Trait<any, any, any, infer TOptions>
532
+ ? TOptions['default']['id'] extends SessionIdSymbol | string
533
+ ? TOptions['default']['id']
534
+ : never
535
535
  : never
536
- : never
537
536
 
538
537
  export type SetEventDefLike<
539
538
  TName extends string,
@@ -554,7 +553,7 @@ export namespace ClientDocumentTableDef {
554
553
  readonly name: `${TName}Set`
555
554
  readonly args: { id: string; value: TType }
556
555
  }
557
- readonly options: { derived: true; clientOnly: true; facts: undefined }
556
+ readonly options: { derived: true; clientOnly: true; facts: undefined; deprecated: undefined }
558
557
  }
559
558
 
560
559
  export type SetEventDef<TName extends string, TType, TOptions extends ClientDocumentTableOptions<TType>> = EventDef<
@@ -144,11 +144,10 @@ describe('getColumnDefForSchema', () => {
144
144
 
145
145
  it('should map tagged unions to json column', () => {
146
146
  const ResultSchema = Schema.Union(
147
- Schema.Struct({
148
- _tag: Schema.Literal('success'),
147
+ Schema.TaggedStruct('success', {
149
148
  value: Schema.String,
150
149
  }),
151
- Schema.Struct({ _tag: Schema.Literal('error'), error: Schema.String }),
150
+ Schema.TaggedStruct('error', { error: Schema.String }),
152
151
  )
153
152
 
154
153
  const columnDef = State.SQLite.getColumnDefForSchema(ResultSchema)
@@ -20,13 +20,12 @@ export type DbSchemaInput = Record<string, TableDefinition<any, any>> | Readonly
20
20
  * - array: we use the table name of each array item (= table definition) as the object key
21
21
  * - object: we discard the keys of the input object and use the table name of each object value (= table definition) as the new object key
22
22
  */
23
- export type DbSchemaFromInputSchema<TSchemaInput extends DbSchemaInput> = TSchemaInput extends ReadonlyArray<
24
- TableDefinition<any, any>
25
- >
26
- ? { [K in TSchemaInput[number] as K['name']]: K }
27
- : TSchemaInput extends Record<string, TableDefinition<any, any>>
28
- ? { [K in keyof TSchemaInput as TSchemaInput[K]['name']]: TSchemaInput[K] }
29
- : never
23
+ export type DbSchemaFromInputSchema<TSchemaInput extends DbSchemaInput> =
24
+ TSchemaInput extends ReadonlyArray<TableDefinition<any, any>>
25
+ ? { [K in TSchemaInput[number] as K['name']]: K }
26
+ : TSchemaInput extends Record<string, TableDefinition<any, any>>
27
+ ? { [K in keyof TSchemaInput as TSchemaInput[K]['name']]: TSchemaInput[K] }
28
+ : never
30
29
 
31
30
  // TODO ensure via runtime check (possibly even via type-level check) that all index names are unique
32
31
  export const makeDbSchema = <TDbSchemaInput extends DbSchemaInput>(
@@ -116,12 +115,8 @@ export type TableDefinition<TName extends string, TColumns extends Columns> = {
116
115
 
117
116
  export type Columns = Record<string, ColumnDefinition<any, any>>
118
117
 
119
- export type IsSingleColumn<TColumns extends Columns | ColumnDefinition<any, any>> = TColumns extends ColumnDefinition<
120
- any,
121
- any
122
- >
123
- ? true
124
- : false
118
+ export type IsSingleColumn<TColumns extends Columns | ColumnDefinition<any, any>> =
119
+ TColumns extends ColumnDefinition<any, any> ? true : false
125
120
 
126
121
  /**
127
122
  * NOTE this is only needed to avoid a TS limitation where `StructSchemaForColumns` in the default case
@@ -214,9 +209,8 @@ export namespace FromColumns {
214
209
 
215
210
  export type RequiredInsertColumnNames<TColumns extends Columns> = keyof RequiredInsertColumns<TColumns>
216
211
 
217
- export type RequiresInsertValues<TColumns extends Columns> = RequiredInsertColumnNames<TColumns> extends never
218
- ? false
219
- : true
212
+ export type RequiresInsertValues<TColumns extends Columns> =
213
+ RequiredInsertColumnNames<TColumns> extends never ? false : true
220
214
 
221
215
  export type InsertRowDecoded<TColumns extends Columns> = Types.Simplify<
222
216
  Pick<RowDecodedAll<TColumns>, RequiredInsertColumnNames<TColumns>> &
@@ -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
 
@@ -242,7 +264,12 @@ export namespace QueryBuilder {
242
264
  ): QueryBuilder<TResult, TTableDef, TWithout | 'row' | 'select'>
243
265
  <TColName extends keyof TTableDef['sqliteDef']['columns']>(
244
266
  col: TColName,
245
- op: QueryBuilder.WhereOps,
267
+ op: QueryBuilder.WhereOps.MultiValue,
268
+ value: ReadonlyArray<TTableDef['sqliteDef']['columns'][TColName]['schema']['Type']>,
269
+ ): QueryBuilder<TResult, TTableDef, TWithout | 'row' | 'select'>
270
+ <TColName extends keyof TTableDef['sqliteDef']['columns']>(
271
+ col: TColName,
272
+ op: QueryBuilder.WhereOps.SingleValue,
246
273
  value: TTableDef['sqliteDef']['columns'][TColName]['schema']['Type'],
247
274
  ): QueryBuilder<TResult, TTableDef, TWithout | 'row' | 'select'>
248
275
  }
@@ -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', () => {