@livestore/livestore 0.0.55-dev.3 → 0.0.56-dev.0

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 (53) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/__tests__/react/fixture.d.ts +0 -5
  3. package/dist/__tests__/react/fixture.d.ts.map +1 -1
  4. package/dist/__tests__/react/fixture.js +1 -2
  5. package/dist/__tests__/react/fixture.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/react/LiveStoreProvider.d.ts +4 -2
  10. package/dist/react/LiveStoreProvider.d.ts.map +1 -1
  11. package/dist/react/LiveStoreProvider.js +20 -5
  12. package/dist/react/LiveStoreProvider.js.map +1 -1
  13. package/dist/react/LiveStoreProvider.test.js +3 -2
  14. package/dist/react/LiveStoreProvider.test.js.map +1 -1
  15. package/dist/react/useQuery.test.js +11 -4
  16. package/dist/react/useQuery.test.js.map +1 -1
  17. package/dist/react/useRow.test.js +13 -5
  18. package/dist/react/useRow.test.js.map +1 -1
  19. package/dist/react/useTemporaryQuery.test.js +5 -2
  20. package/dist/react/useTemporaryQuery.test.js.map +1 -1
  21. package/dist/reactiveQueries/graphql.d.ts.map +1 -1
  22. package/dist/reactiveQueries/graphql.js.map +1 -1
  23. package/dist/reactiveQueries/sql.d.ts +9 -12
  24. package/dist/reactiveQueries/sql.d.ts.map +1 -1
  25. package/dist/reactiveQueries/sql.js +39 -69
  26. package/dist/reactiveQueries/sql.js.map +1 -1
  27. package/dist/reactiveQueries/sql.test.js +9 -5
  28. package/dist/reactiveQueries/sql.test.js.map +1 -1
  29. package/dist/row-query.d.ts +6 -4
  30. package/dist/row-query.d.ts.map +1 -1
  31. package/dist/row-query.js +5 -12
  32. package/dist/row-query.js.map +1 -1
  33. package/dist/store-devtools.d.ts +5 -4
  34. package/dist/store-devtools.d.ts.map +1 -1
  35. package/dist/store-devtools.js +4 -9
  36. package/dist/store-devtools.js.map +1 -1
  37. package/dist/store.d.ts.map +1 -1
  38. package/dist/store.js +2 -2
  39. package/dist/store.js.map +1 -1
  40. package/package.json +5 -9
  41. package/src/__tests__/react/fixture.tsx +1 -3
  42. package/src/index.ts +1 -1
  43. package/src/react/LiveStoreProvider.test.tsx +3 -2
  44. package/src/react/LiveStoreProvider.tsx +28 -7
  45. package/src/react/useQuery.test.tsx +11 -4
  46. package/src/react/useRow.test.tsx +13 -9
  47. package/src/react/useTemporaryQuery.test.tsx +5 -2
  48. package/src/reactiveQueries/graphql.ts +5 -1
  49. package/src/reactiveQueries/sql.test.ts +9 -5
  50. package/src/reactiveQueries/sql.ts +61 -86
  51. package/src/row-query.ts +17 -21
  52. package/src/store-devtools.ts +11 -10
  53. package/src/store.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livestore/livestore",
3
- "version": "0.0.55-dev.3",
3
+ "version": "0.0.56-dev.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -30,9 +30,9 @@
30
30
  "dependencies": {
31
31
  "@graphql-typed-document-node/core": "^3.2.0",
32
32
  "@opentelemetry/api": "^1.9.0",
33
- "@livestore/common": "0.0.55-dev.3",
34
- "@livestore/utils": "0.0.55-dev.3",
35
- "effect-db-schema": "0.0.55-dev.3"
33
+ "@livestore/utils": "0.0.56-dev.0",
34
+ "effect-db-schema": "0.0.56-dev.0",
35
+ "@livestore/common": "0.0.56-dev.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@opentelemetry/sdk-trace-base": "1.25.1",
@@ -46,18 +46,14 @@
46
46
  "typescript": "5.5.4",
47
47
  "vite": "5.3.5",
48
48
  "vitest": "^2.0.4",
49
- "@livestore/web": "0.0.55-dev.3"
49
+ "@livestore/web": "0.0.56-dev.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@tauri-apps/api": "^1.4.0",
53
52
  "graphql": "16.x",
54
53
  "react": "^18",
55
54
  "react-dom": "^18"
56
55
  },
57
56
  "peerDependenciesMeta": {
58
- "@tauri-apps/api": {
59
- "optional": true
60
- },
61
57
  "graphql": {
62
58
  "optional": true
63
59
  }
@@ -5,7 +5,7 @@ import React from 'react'
5
5
 
6
6
  import { globalReactivityGraph } from '../../global-state.js'
7
7
  import type { LiveStoreContext } from '../../index.js'
8
- import { createStorePromise, DbSchema, makeReactivityGraph, makeSchema, ParseUtils, sql } from '../../index.js'
8
+ import { createStorePromise, DbSchema, makeReactivityGraph, makeSchema, sql } from '../../index.js'
9
9
  import * as LiveStoreReact from '../../react/index.js'
10
10
 
11
11
  export type Todo = {
@@ -57,8 +57,6 @@ const AppRouterSchema = DbSchema.table(
57
57
  export const tables = { todos, app, userInfo, AppRouterSchema }
58
58
  export const schema = makeSchema({ tables })
59
59
 
60
- export const parseTodos = ParseUtils.many(todos)
61
-
62
60
  export const makeTodoMvc = async ({
63
61
  otelTracer,
64
62
  otelContext,
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export type {
22
22
  Effect,
23
23
  } from './reactive.js'
24
24
  export { LiveStoreJSQuery, computed } from './reactiveQueries/js.js'
25
- export { LiveStoreSQLQuery, querySQL, type MapRows } from './reactiveQueries/sql.js'
25
+ export { LiveStoreSQLQuery, querySQL } from './reactiveQueries/sql.js'
26
26
  export { LiveStoreGraphQLQuery, queryGraphQL } from './reactiveQueries/graphql.js'
27
27
  export {
28
28
  type GetAtomResult,
@@ -1,11 +1,12 @@
1
1
  import type { BootDb } from '@livestore/common'
2
2
  import { sql } from '@livestore/common'
3
+ import { Schema } from '@livestore/utils/effect'
3
4
  import { makeInMemoryAdapter } from '@livestore/web'
4
5
  import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
5
6
  import React from 'react'
6
7
  import { describe, expect, it } from 'vitest'
7
8
 
8
- import { parseTodos, schema } from '../__tests__/react/fixture.js'
9
+ import { schema, tables } from '../__tests__/react/fixture.js'
9
10
  import { querySQL } from '../reactiveQueries/sql.js'
10
11
  import * as LiveStoreReact from './index.js'
11
12
  import { LiveStoreProvider } from './LiveStoreProvider.js'
@@ -14,7 +15,7 @@ describe('LiveStoreProvider', () => {
14
15
  it('simple', async () => {
15
16
  let appRenderCount = 0
16
17
 
17
- const allTodos$ = querySQL(`select * from todos`, { map: parseTodos })
18
+ const allTodos$ = querySQL(`select * from todos`, { schema: Schema.Array(tables.todos.schema) })
18
19
 
19
20
  const App = () => {
20
21
  appRenderCount++
@@ -21,14 +21,23 @@ interface LiveStoreProviderProps<GraphQLContext> {
21
21
  graphQLOptions?: GraphQLOptions<GraphQLContext>
22
22
  otelOptions?: OtelOptions
23
23
  renderLoading: (status: BootStatus) => ReactElement
24
+ renderError?: (error: UnexpectedError | unknown) => ReactElement
25
+ renderShutdown?: () => ReactElement
24
26
  adapter: StoreAdapterFactory
25
27
  batchUpdates?: (run: () => void) => void
26
28
  disableDevtools?: boolean
27
29
  signal?: AbortSignal
28
30
  }
29
31
 
32
+ const defaultRenderError = (error: UnexpectedError | unknown) => (
33
+ <>{Schema.is(UnexpectedError)(error) ? error.toString() : errorToString(error)}</>
34
+ )
35
+ const defaultRenderShutdown = () => <>LiveStore Shutdown</>
36
+
30
37
  export const LiveStoreProvider = <GraphQLContext extends BaseGraphQLContext>({
31
38
  renderLoading,
39
+ renderError = defaultRenderError,
40
+ renderShutdown = defaultRenderShutdown,
32
41
  graphQLOptions,
33
42
  otelOptions,
34
43
  children,
@@ -51,19 +60,15 @@ export const LiveStoreProvider = <GraphQLContext extends BaseGraphQLContext>({
51
60
  })
52
61
 
53
62
  if (storeCtx.stage === 'error') {
54
- return (
55
- <div>
56
- {Schema.is(UnexpectedError)(storeCtx.error) ? storeCtx.error.toString() : errorToString(storeCtx.error)}
57
- </div>
58
- )
63
+ return renderError(storeCtx.error)
59
64
  }
60
65
 
61
66
  if (storeCtx.stage === 'shutdown') {
62
- return <div>LiveStore Shutdown</div>
67
+ return renderShutdown()
63
68
  }
64
69
 
65
70
  if (storeCtx.stage !== 'running') {
66
- return <div>{renderLoading(storeCtx)}</div>
71
+ return renderLoading(storeCtx)
67
72
  }
68
73
 
69
74
  window.__debugLiveStore = storeCtx.store
@@ -71,6 +76,18 @@ export const LiveStoreProvider = <GraphQLContext extends BaseGraphQLContext>({
71
76
  return <LiveStoreContext.Provider value={storeCtx}>{children}</LiveStoreContext.Provider>
72
77
  }
73
78
 
79
+ type SchemaKey = string
80
+ const semaphoreMap = new Map<SchemaKey, Effect.Semaphore>()
81
+
82
+ const withSemaphore = (schemaKey: SchemaKey) => {
83
+ let semaphore = semaphoreMap.get(schemaKey)
84
+ if (!semaphore) {
85
+ semaphore = Effect.makeSemaphore(1).pipe(Effect.runSync)
86
+ semaphoreMap.set(schemaKey, semaphore)
87
+ }
88
+ return semaphore.withPermits(1)
89
+ }
90
+
74
91
  const useCreateStore = <GraphQLContext extends BaseGraphQLContext>({
75
92
  schema,
76
93
  graphQLOptions,
@@ -195,6 +212,10 @@ const useCreateStore = <GraphQLContext extends BaseGraphQLContext>({
195
212
  )
196
213
  }).pipe(
197
214
  Effect.scoped,
215
+ // NOTE we're running the code above in a semaphore to make sure a previous store is always fully
216
+ // shutdown before a new one is created - especially when shutdown logic is async. You can't trust `React.useEffect`.
217
+ // Thank you to Mattia Manzati for this idea.
218
+ withSemaphore(schema.key),
198
219
  Effect.tapCauseLogPretty,
199
220
  Effect.annotateLogs({ thread: 'window' }),
200
221
  Effect.provide(Logger.pretty),
@@ -1,8 +1,9 @@
1
+ import { Schema } from '@livestore/utils/effect'
1
2
  import { renderHook } from '@testing-library/react'
2
3
  import React from 'react'
3
4
  import { describe, expect, it } from 'vitest'
4
5
 
5
- import { makeTodoMvc, parseTodos, todos } from '../__tests__/react/fixture.js'
6
+ import { makeTodoMvc, tables, todos } from '../__tests__/react/fixture.js'
6
7
  import { querySQL } from '../reactiveQueries/sql.js'
7
8
  import * as LiveStoreReact from './index.js'
8
9
 
@@ -12,7 +13,7 @@ describe('useQuery', () => {
12
13
 
13
14
  const renderCount = makeRenderCount()
14
15
 
15
- const allTodos$ = querySQL(`select * from todos`, { map: parseTodos })
16
+ const allTodos$ = querySQL(`select * from todos`, { schema: Schema.Array(tables.todos.schema) })
16
17
 
17
18
  const { result } = renderHook(
18
19
  () => {
@@ -38,8 +39,14 @@ describe('useQuery', () => {
38
39
 
39
40
  const renderCount = makeRenderCount()
40
41
 
41
- const todo1$ = querySQL(`select * from todos where id = 't1'`, { label: 'libraryTracksView1', map: parseTodos })
42
- const todo2$ = querySQL(`select * from todos where id = 't2'`, { label: 'libraryTracksView2', map: parseTodos })
42
+ const todo1$ = querySQL(`select * from todos where id = 't1'`, {
43
+ label: 'libraryTracksView1',
44
+ schema: Schema.Array(tables.todos.schema),
45
+ })
46
+ const todo2$ = querySQL(`select * from todos where id = 't2'`, {
47
+ label: 'libraryTracksView2',
48
+ schema: Schema.Array(tables.todos.schema),
49
+ })
43
50
 
44
51
  store.mutate(
45
52
  todos.insert({ id: 't1', text: 'buy milk', completed: false }),
@@ -1,12 +1,11 @@
1
- import { ReadonlyRecord } from '@livestore/utils/effect'
1
+ import { ReadonlyRecord, Schema } from '@livestore/utils/effect'
2
2
  import * as otel from '@opentelemetry/api'
3
3
  import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
4
4
  import { render, renderHook } from '@testing-library/react'
5
5
  import React from 'react'
6
6
  import { describe, expect, it } from 'vitest'
7
7
 
8
- import type { Todo } from '../__tests__/react/fixture.js'
9
- import { makeTodoMvc, todos } from '../__tests__/react/fixture.js'
8
+ import { makeTodoMvc, tables, todos } from '../__tests__/react/fixture.js'
10
9
  import { getSimplifiedRootSpan } from '../__tests__/react/utils/otel.js'
11
10
  import * as LiveStore from '../index.js'
12
11
  import * as LiveStoreReact from './index.js'
@@ -118,7 +117,11 @@ describe.concurrent('useRow', () => {
118
117
  using inputs = await makeTodoMvc({ useGlobalReactivityGraph: false })
119
118
  const { wrapper, store, reactivityGraph, makeRenderCount, AppRouterSchema } = inputs
120
119
 
121
- const allTodos$ = LiveStore.querySQL<Todo[]>(`select * from todos`, { label: 'allTodos', reactivityGraph })
120
+ const allTodos$ = LiveStore.querySQL(`select * from todos`, {
121
+ label: 'allTodos',
122
+ schema: Schema.Array(tables.todos.schema),
123
+ reactivityGraph,
124
+ })
122
125
 
123
126
  const appRouterRenderCount = makeRenderCount()
124
127
  let globalSetState: LiveStoreReact.StateSetters<typeof AppRouterSchema> | undefined
@@ -176,7 +179,7 @@ describe.concurrent('useRow', () => {
176
179
 
177
180
  expect(appRouterRenderCount.val).toBe(2)
178
181
  expect(renderResult.getByRole('content').innerHTML).toMatchInlineSnapshot(
179
- `"{"id":"t1","text":"buy milk","completed":false}"`,
182
+ `"{"completed":false,"id":"t1","text":"buy milk"}"`,
180
183
  )
181
184
 
182
185
  expect(renderResult.getByRole('current-id').innerHTML).toMatchInlineSnapshot('"Current Task Id: t1"')
@@ -214,10 +217,11 @@ describe.concurrent('useRow', () => {
214
217
  const [_row, _setRow, rowState$] = LiveStoreReact.useRow(AppComponentSchema, userId, { reactivityGraph })
215
218
  const todos = LiveStoreReact.useTemporaryQuery(
216
219
  () =>
217
- LiveStore.querySQL<any[]>(
218
- (get) => LiveStore.sql`select * from todos where text like '%${get(rowState$).text}%'`,
219
- { reactivityGraph, label: 'todosFiltered' },
220
- ),
220
+ LiveStore.querySQL((get) => LiveStore.sql`select * from todos where text like '%${get(rowState$).text}%'`, {
221
+ schema: Schema.Array(tables.todos.schema),
222
+ reactivityGraph,
223
+ label: 'todosFiltered',
224
+ }),
221
225
  userId,
222
226
  )
223
227
 
@@ -1,7 +1,8 @@
1
+ import { Schema } from '@livestore/utils/effect'
1
2
  import { renderHook } from '@testing-library/react'
2
3
  import { describe, expect, it } from 'vitest'
3
4
 
4
- import { makeTodoMvc, parseTodos, todos } from '../__tests__/react/fixture.js'
5
+ import { makeTodoMvc, tables, todos } from '../__tests__/react/fixture.js'
5
6
  import type * as LiveStore from '../index.js'
6
7
  import { querySQL } from '../reactiveQueries/sql.js'
7
8
  import * as LiveStoreReact from './index.js'
@@ -24,7 +25,9 @@ describe('useTemporaryQuery', () => {
24
25
  renderCount.inc()
25
26
 
26
27
  return LiveStoreReact.useTemporaryQuery(() => {
27
- const query$ = querySQL(`select * from todos where id = '${id}'`, { map: parseTodos })
28
+ const query$ = querySQL(`select * from todos where id = '${id}'`, {
29
+ schema: Schema.Array(tables.todos.schema),
30
+ })
28
31
  queryMap.set(id, query$)
29
32
  return query$
30
33
  }, id)
@@ -25,7 +25,11 @@ export const queryGraphQL = <
25
25
  label,
26
26
  reactivityGraph,
27
27
  map,
28
- }: { label?: string; reactivityGraph?: ReactivityGraph; map?: MapResult<TResultMapped, TResult> } = {},
28
+ }: {
29
+ label?: string
30
+ reactivityGraph?: ReactivityGraph
31
+ map?: MapResult<TResultMapped, TResult>
32
+ } = {},
29
33
  ): LiveQuery<TResultMapped, QueryInfoNone> =>
30
34
  new LiveStoreGraphQLQuery({ document, genVariableValues, label, reactivityGraph, map })
31
35
 
@@ -1,10 +1,11 @@
1
+ import { Schema } from '@livestore/utils/effect'
1
2
  import * as otel from '@opentelemetry/api'
2
3
  import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
3
4
  import { describe, expect, it } from 'vitest'
4
5
 
5
- import { makeTodoMvc, todos } from '../__tests__/react/fixture.js'
6
+ import { makeTodoMvc, tables } from '../__tests__/react/fixture.js'
6
7
  import { getSimplifiedRootSpan } from '../__tests__/react/utils/otel.js'
7
- import { computed, ParseUtils, querySQL, rawSqlMutation, sql } from '../index.js'
8
+ import { computed, querySQL, rawSqlMutation, sql } from '../index.js'
8
9
 
9
10
  /*
10
11
  TODO write tests for:
@@ -45,7 +46,10 @@ describe('otel', () => {
45
46
  using inputs = await makeQuery()
46
47
  const { store, exporter, span } = inputs
47
48
 
48
- const query = querySQL(`select * from todos`, { queriedTables: new Set(['todos']) })
49
+ const query = querySQL(`select * from todos`, {
50
+ schema: Schema.Array(tables.todos.schema),
51
+ queriedTables: new Set(['todos']),
52
+ })
49
53
  expect(query.run()).toMatchInlineSnapshot('[]')
50
54
 
51
55
  store.mutate(rawSqlMutation({ sql: sql`INSERT INTO todos (id, text, completed) VALUES ('t1', 'buy milk', 0)` }))
@@ -53,7 +57,7 @@ describe('otel', () => {
53
57
  expect(query.run()).toMatchInlineSnapshot(`
54
58
  [
55
59
  {
56
- "completed": 0,
60
+ "completed": false,
57
61
  "id": "t1",
58
62
  "text": "buy milk",
59
63
  },
@@ -172,7 +176,7 @@ describe('otel', () => {
172
176
  const filter = computed(() => `where completed = 0`, { label: 'where-filter' })
173
177
  const query = querySQL((get) => `select * from todos ${get(filter)}`, {
174
178
  label: 'all todos',
175
- map: ParseUtils.first(todos, defaultTodo),
179
+ schema: Schema.Array(tables.todos.schema).pipe(Schema.headOrElse(() => defaultTodo)),
176
180
  })
177
181
 
178
182
  expect(query.run()).toMatchInlineSnapshot(`
@@ -1,26 +1,24 @@
1
1
  import { type Bindable, prepareBindValues, type QueryInfo, type QueryInfoNone } from '@livestore/common'
2
2
  import { shouldNeverHappen } from '@livestore/utils'
3
- import { Schema, TreeFormatter } from '@livestore/utils/effect'
3
+ import { Schema, SchemaEquivalence, TreeFormatter } from '@livestore/utils/effect'
4
4
  import * as otel from '@opentelemetry/api'
5
5
 
6
6
  import { globalReactivityGraph } from '../global-state.js'
7
7
  import type { Thunk } from '../reactive.js'
8
+ import { NOT_REFRESHED_YET } from '../reactive.js'
8
9
  import type { RefreshReason } from '../store.js'
9
10
  import { getDurationMsFromSpan } from '../utils/otel.js'
10
11
  import type { GetAtomResult, LiveQuery, QueryContext, ReactivityGraph } from './base-class.js'
11
12
  import { LiveStoreQueryBase, makeGetAtomResult } from './base-class.js'
12
13
 
13
- export type MapRows<TResult, TRaw = any> =
14
- | ((rows: ReadonlyArray<TRaw>) => TResult)
15
- | Schema.Schema<TResult, ReadonlyArray<TRaw>, unknown>
16
-
17
14
  /**
18
15
  * NOTE `querySQL` is only supposed to read data. Don't use it to insert/update/delete data but use mutations instead.
19
16
  */
20
- export const querySQL = <TResult, TRaw = any>(
17
+ export const querySQL = <TResultSchema, TResult = TResultSchema>(
21
18
  query: string | ((get: GetAtomResult) => string),
22
- options?: {
23
- map?: MapRows<TResult, TRaw>
19
+ options: {
20
+ schema: Schema.Schema<TResultSchema, ReadonlyArray<any>>
21
+ map?: (rows: TResultSchema) => TResult
24
22
  /**
25
23
  * Can be provided explicitly to slightly speed up initial query performance
26
24
  *
@@ -32,21 +30,23 @@ export const querySQL = <TResult, TRaw = any>(
32
30
  reactivityGraph?: ReactivityGraph
33
31
  },
34
32
  ): LiveQuery<TResult, QueryInfoNone> =>
35
- new LiveStoreSQLQuery<TResult, QueryInfoNone>({
33
+ new LiveStoreSQLQuery<TResultSchema, TResult, QueryInfoNone>({
36
34
  label: options?.label,
37
35
  genQueryString: query,
38
36
  queriedTables: options?.queriedTables,
39
37
  bindValues: options?.bindValues,
40
38
  reactivityGraph: options?.reactivityGraph,
41
39
  map: options?.map,
40
+ schema: options.schema,
42
41
  queryInfo: { _tag: 'None' },
43
42
  })
44
43
 
45
44
  /* An object encapsulating a reactive SQL query */
46
- export class LiveStoreSQLQuery<TResult, TQueryInfo extends QueryInfo = QueryInfoNone> extends LiveStoreQueryBase<
47
- TResult,
48
- TQueryInfo
49
- > {
45
+ export class LiveStoreSQLQuery<
46
+ TResultSchema,
47
+ TResult = TResultSchema,
48
+ TQueryInfo extends QueryInfo = QueryInfoNone,
49
+ > extends LiveStoreQueryBase<TResult, TQueryInfo> {
50
50
  _tag: 'sql' = 'sql'
51
51
 
52
52
  /** A reactive thunk representing the query text */
@@ -62,7 +62,8 @@ export class LiveStoreSQLQuery<TResult, TQueryInfo extends QueryInfo = QueryInfo
62
62
  /** Currently only used by `rowQuery` for lazy table migrations and eager default row insertion */
63
63
  private execBeforeFirstRun
64
64
 
65
- private mapRows
65
+ private mapResult: (rows: TResultSchema) => TResult
66
+ private schema: Schema.Schema<TResultSchema, ReadonlyArray<any>>
66
67
 
67
68
  queryInfo: TQueryInfo
68
69
 
@@ -70,8 +71,9 @@ export class LiveStoreSQLQuery<TResult, TQueryInfo extends QueryInfo = QueryInfo
70
71
  genQueryString,
71
72
  queriedTables,
72
73
  bindValues,
73
- label: label_,
74
+ label = genQueryString.toString(),
74
75
  reactivityGraph,
76
+ schema,
75
77
  map,
76
78
  execBeforeFirstRun,
77
79
  queryInfo,
@@ -81,51 +83,20 @@ export class LiveStoreSQLQuery<TResult, TQueryInfo extends QueryInfo = QueryInfo
81
83
  queriedTables?: Set<string>
82
84
  bindValues?: Bindable
83
85
  reactivityGraph?: ReactivityGraph
84
- map?: MapRows<TResult>
86
+ schema: Schema.Schema<TResultSchema, ReadonlyArray<any>>
87
+ map?: (rows: TResultSchema) => TResult
85
88
  execBeforeFirstRun?: (ctx: QueryContext) => void
86
89
  queryInfo?: TQueryInfo
87
90
  }) {
88
91
  super()
89
92
 
90
- const label = label_ ?? genQueryString.toString()
91
93
  this.label = `sql(${label})`
92
94
  this.reactivityGraph = reactivityGraph ?? globalReactivityGraph
93
95
  this.execBeforeFirstRun = execBeforeFirstRun
94
96
  this.queryInfo = queryInfo ?? ({ _tag: 'None' } as TQueryInfo)
95
- this.mapRows =
96
- map === undefined
97
- ? (rows: any) => rows as TResult
98
- : Schema.isSchema(map)
99
- ? (rows: any, opts: { sqlString: string }) => {
100
- const parseResult = Schema.decodeEither(map as Schema.Schema<TResult, ReadonlyArray<any>>)(rows)
101
- if (parseResult._tag === 'Left') {
102
- const parseErrorStr = TreeFormatter.formatErrorSync(parseResult.left)
103
- const expectedSchemaStr = String(map.ast)
104
- const bindValuesStr = bindValues === undefined ? '' : `\nBind values: ${JSON.stringify(bindValues)}`
105
-
106
- console.error(
107
- `\
108
- Error parsing SQL query result.
109
-
110
- Query: ${opts.sqlString}\
111
- ${bindValuesStr}
112
-
113
- Expected schema: ${expectedSchemaStr}
114
97
 
115
- Error: ${parseErrorStr}
116
-
117
- Result:`,
118
- rows,
119
- )
120
- // console.error(`Error parsing SQL query result: ${TreeFormatter.formatErrorSync(parseResult.left)}`)
121
- return shouldNeverHappen(`Error parsing SQL query result: ${parseResult.left}`)
122
- } else {
123
- return parseResult.right as TResult
124
- }
125
- }
126
- : typeof map === 'function'
127
- ? map
128
- : shouldNeverHappen(`Invalid map function ${map}`)
98
+ this.schema = schema
99
+ this.mapResult = map === undefined ? (rows: any) => rows as TResult : map
129
100
 
130
101
  let queryString$OrQueryString: string | Thunk<string, QueryContext, RefreshReason>
131
102
  if (typeof genQueryString === 'function') {
@@ -137,7 +108,11 @@ Result:`,
137
108
  setDebugInfo({ _tag: 'js', label: `${label}:queryString`, query: queryString, durationMs })
138
109
  return queryString
139
110
  },
140
- { label: `${label}:queryString`, meta: { liveStoreThunkType: 'sqlQueryString' } },
111
+ {
112
+ label: `${label}:queryString`,
113
+ meta: { liveStoreThunkType: 'sqlQueryString' },
114
+ equal: (a, b) => a === b,
115
+ },
141
116
  )
142
117
 
143
118
  this.queryString$ = queryString$OrQueryString
@@ -149,6 +124,15 @@ Result:`,
149
124
 
150
125
  const queriedTablesRef = { current: queriedTables }
151
126
 
127
+ const schemaEqual = SchemaEquivalence.make(schema)
128
+ // TODO also support derived equality for `map` (probably will depend on having an easy way to transform a schema without an `encode` step)
129
+ // This would mean dropping the `map` option
130
+ const equal =
131
+ map === undefined
132
+ ? (a: TResult, b: TResult) =>
133
+ a === NOT_REFRESHED_YET || b === NOT_REFRESHED_YET ? false : schemaEqual(a as any, b as any)
134
+ : undefined
135
+
152
136
  const results$ = this.reactivityGraph.makeThunk<TResult>(
153
137
  (get, setDebugInfo, { store, otelTracer, rootOtelContext }, otelContext) =>
154
138
  otelTracer.startActiveSpan(
@@ -189,7 +173,31 @@ Result:`,
189
173
 
190
174
  span.setAttribute('sql.rowsCount', rawResults.length)
191
175
 
192
- const result = this.mapRows(rawResults, { sqlString })
176
+ const parsedResult = Schema.decodeEither(this.schema)(rawResults)
177
+
178
+ if (parsedResult._tag === 'Left') {
179
+ const parseErrorStr = TreeFormatter.formatErrorSync(parsedResult.left)
180
+ const expectedSchemaStr = String(this.schema.ast)
181
+ const bindValuesStr = bindValues === undefined ? '' : `\nBind values: ${JSON.stringify(bindValues)}`
182
+
183
+ console.error(
184
+ `\
185
+ Error parsing SQL query result.
186
+
187
+ Query: ${sqlString}\
188
+ ${bindValuesStr}
189
+
190
+ Expected schema: ${expectedSchemaStr}
191
+
192
+ Error: ${parseErrorStr}
193
+
194
+ Result:`,
195
+ rawResults,
196
+ )
197
+ return shouldNeverHappen(`Error parsing SQL query result: ${parsedResult.left}`)
198
+ }
199
+
200
+ const result = this.mapResult(parsedResult.right)
193
201
 
194
202
  span.end()
195
203
 
@@ -202,45 +210,12 @@ Result:`,
202
210
  return result
203
211
  },
204
212
  ),
205
- { label: queryLabel },
213
+ { label: queryLabel, equal },
206
214
  )
207
215
 
208
216
  this.results$ = results$
209
217
  }
210
218
 
211
- /**
212
- * Returns a new reactive query that contains the result of
213
- * running an arbitrary JS computation on the results of this SQL query.
214
- */
215
- // pipe = <U>(fn: (result: Result, get: GetAtomResult) => U): LiveStoreJSQuery<U> =>
216
- // new LiveStoreJSQuery({
217
- // fn: (get) => {
218
- // const results = get(this.results$!)
219
- // return fn(results, get)
220
- // },
221
- // label: `${this.label}:js`,
222
- // onDestroy: () => this.destroy(),
223
- // reactivityGraph: this.reactivityGraph,
224
- // queryInfo: undefined,
225
- // })
226
-
227
- /** Returns a reactive query */
228
- // getFirstRow = (args?: { defaultValue?: Result }) =>
229
- // new LiveStoreJSQuery({
230
- // fn: (get) => {
231
- // const results = get(this.results$!)
232
- // if (results.length === 0 && args?.defaultValue === undefined) {
233
- // // const queryLabel = this._tag === 'sql' ? this.queryString$!.computeResult(otelContext) : this.label
234
- // const queryLabel = this.label
235
- // return shouldNeverHappen(`Expected query ${queryLabel} to return at least one result`)
236
- // }
237
- // return results[0] ?? args!.defaultValue!
238
- // },
239
- // label: `${this.label}:first`,
240
- // onDestroy: () => this.destroy(),
241
- // reactivityGraph: this.reactivityGraph,
242
- // })
243
-
244
219
  destroy = () => {
245
220
  if (this.queryString$ !== undefined) {
246
221
  this.reactivityGraph.destroyNode(this.queryString$)