@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/ClientSessionLeaderThreadProxy.d.ts +7 -0
- package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
- package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
- package/dist/adapter-types.d.ts +23 -0
- package/dist/adapter-types.d.ts.map +1 -1
- package/dist/adapter-types.js +27 -1
- package/dist/adapter-types.js.map +1 -1
- package/dist/devtools/devtools-messages-client-session.d.ts +42 -22
- package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
- package/dist/devtools/devtools-messages-client-session.js +12 -1
- package/dist/devtools/devtools-messages-client-session.js.map +1 -1
- package/dist/devtools/devtools-messages-common.d.ts +12 -6
- package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
- package/dist/devtools/devtools-messages-common.js +7 -2
- package/dist/devtools/devtools-messages-common.js.map +1 -1
- package/dist/devtools/devtools-messages-leader.d.ts +45 -25
- package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
- package/dist/devtools/devtools-messages-leader.js +12 -1
- package/dist/devtools/devtools-messages-leader.js.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.js +10 -10
- package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
- package/dist/leader-thread/leader-worker-devtools.js +9 -0
- package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.d.ts +4 -2
- package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.js +5 -1
- package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
- package/dist/leader-thread/materialize-event.d.ts.map +1 -1
- package/dist/leader-thread/materialize-event.js +3 -0
- package/dist/leader-thread/materialize-event.js.map +1 -1
- package/dist/schema/EventDef/define.d.ts +14 -0
- package/dist/schema/EventDef/define.d.ts.map +1 -1
- package/dist/schema/EventDef/define.js +1 -0
- package/dist/schema/EventDef/define.js.map +1 -1
- package/dist/schema/EventDef/deprecated.d.ts +99 -0
- package/dist/schema/EventDef/deprecated.d.ts.map +1 -0
- package/dist/schema/EventDef/deprecated.js +144 -0
- package/dist/schema/EventDef/deprecated.js.map +1 -0
- package/dist/schema/EventDef/deprecated.test.d.ts +2 -0
- package/dist/schema/EventDef/deprecated.test.d.ts.map +1 -0
- package/dist/schema/EventDef/deprecated.test.js +95 -0
- package/dist/schema/EventDef/deprecated.test.js.map +1 -0
- package/dist/schema/EventDef/event-def.d.ts +4 -0
- package/dist/schema/EventDef/event-def.d.ts.map +1 -1
- package/dist/schema/EventDef/mod.d.ts +1 -0
- package/dist/schema/EventDef/mod.d.ts.map +1 -1
- package/dist/schema/EventDef/mod.js +1 -0
- package/dist/schema/EventDef/mod.js.map +1 -1
- package/dist/schema/LiveStoreEvent/client.d.ts +6 -6
- package/dist/schema/state/sqlite/client-document-def.d.ts +1 -0
- package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.js +17 -8
- package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.test.js +120 -1
- package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
- package/dist/schema/state/sqlite/query-builder/api.d.ts +27 -11
- package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
- package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
- package/dist/schema/state/sqlite/query-builder/astToSql.js +71 -1
- package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
- package/dist/schema/state/sqlite/query-builder/impl.test.js +109 -1
- package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.js +6 -2
- package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
- package/dist/version.d.ts +7 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +8 -1
- package/dist/version.js.map +1 -1
- package/package.json +4 -4
- package/src/ClientSessionLeaderThreadProxy.ts +7 -0
- package/src/adapter-types.ts +30 -0
- package/src/devtools/devtools-messages-client-session.ts +12 -0
- package/src/devtools/devtools-messages-common.ts +7 -3
- package/src/devtools/devtools-messages-leader.ts +12 -0
- package/src/leader-thread/LeaderSyncProcessor.ts +81 -40
- package/src/leader-thread/leader-worker-devtools.ts +11 -0
- package/src/leader-thread/make-leader-thread-layer.ts +8 -0
- package/src/leader-thread/materialize-event.ts +4 -0
- package/src/schema/EventDef/define.ts +16 -0
- package/src/schema/EventDef/deprecated.test.ts +128 -0
- package/src/schema/EventDef/deprecated.ts +175 -0
- package/src/schema/EventDef/event-def.ts +5 -0
- package/src/schema/EventDef/mod.ts +1 -0
- package/src/schema/state/sqlite/client-document-def.test.ts +140 -2
- package/src/schema/state/sqlite/client-document-def.ts +18 -9
- package/src/schema/state/sqlite/query-builder/api.ts +25 -3
- package/src/schema/state/sqlite/query-builder/astToSql.ts +81 -1
- package/src/schema/state/sqlite/query-builder/impl.test.ts +141 -1
- package/src/sync/ClientSessionSyncProcessor.ts +26 -13
- 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 {
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| {
|
|
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
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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.
|