@livestore/common 0.0.47 → 0.0.48-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 (108) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/__tests__/fixture.d.ts +68 -0
  3. package/dist/__tests__/fixture.d.ts.map +1 -0
  4. package/dist/__tests__/fixture.js +16 -0
  5. package/dist/__tests__/fixture.js.map +1 -0
  6. package/dist/adapter-types.d.ts +86 -0
  7. package/dist/adapter-types.d.ts.map +1 -0
  8. package/dist/adapter-types.js +2 -0
  9. package/dist/adapter-types.js.map +1 -0
  10. package/dist/derived-mutations.d.ts +107 -0
  11. package/dist/derived-mutations.d.ts.map +1 -0
  12. package/dist/derived-mutations.js +51 -0
  13. package/dist/derived-mutations.js.map +1 -0
  14. package/dist/derived-mutations.test.d.ts +2 -0
  15. package/dist/derived-mutations.test.d.ts.map +1 -0
  16. package/dist/derived-mutations.test.js +92 -0
  17. package/dist/derived-mutations.test.js.map +1 -0
  18. package/dist/index.d.ts +6 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/init-singleton-tables.d.ts +2 -2
  23. package/dist/init-singleton-tables.d.ts.map +1 -1
  24. package/dist/init-singleton-tables.js.map +1 -1
  25. package/dist/mutation.d.ts.map +1 -1
  26. package/dist/query-info.d.ts +47 -0
  27. package/dist/query-info.d.ts.map +1 -0
  28. package/dist/query-info.js +38 -0
  29. package/dist/query-info.js.map +1 -0
  30. package/dist/rehydrate-from-mutationlog.d.ts +6 -5
  31. package/dist/rehydrate-from-mutationlog.d.ts.map +1 -1
  32. package/dist/rehydrate-from-mutationlog.js +24 -7
  33. package/dist/rehydrate-from-mutationlog.js.map +1 -1
  34. package/dist/schema/index.d.ts +33 -23
  35. package/dist/schema/index.d.ts.map +1 -1
  36. package/dist/schema/index.js +31 -15
  37. package/dist/schema/index.js.map +1 -1
  38. package/dist/schema/mutations.d.ts +16 -0
  39. package/dist/schema/mutations.d.ts.map +1 -1
  40. package/dist/schema/mutations.js +18 -8
  41. package/dist/schema/mutations.js.map +1 -1
  42. package/dist/schema/parse-utils.d.ts +14 -4
  43. package/dist/schema/parse-utils.d.ts.map +1 -1
  44. package/dist/schema/parse-utils.js +3 -3
  45. package/dist/schema/parse-utils.js.map +1 -1
  46. package/dist/schema/system-tables.d.ts +239 -67
  47. package/dist/schema/system-tables.d.ts.map +1 -1
  48. package/dist/schema/system-tables.js +24 -3
  49. package/dist/schema/system-tables.js.map +1 -1
  50. package/dist/schema/table-def.d.ts +53 -10
  51. package/dist/schema/table-def.d.ts.map +1 -1
  52. package/dist/schema/table-def.js +27 -12
  53. package/dist/schema/table-def.js.map +1 -1
  54. package/dist/schema-management/common.d.ts +5 -0
  55. package/dist/schema-management/common.d.ts.map +1 -0
  56. package/dist/schema-management/common.js +22 -0
  57. package/dist/schema-management/common.js.map +1 -0
  58. package/dist/schema-management/migrations.d.ts +18 -0
  59. package/dist/schema-management/migrations.d.ts.map +1 -0
  60. package/dist/{migrations.js → schema-management/migrations.js} +29 -36
  61. package/dist/schema-management/migrations.js.map +1 -0
  62. package/dist/schema-management/validate-mutation-defs.d.ts +16 -0
  63. package/dist/schema-management/validate-mutation-defs.d.ts.map +1 -0
  64. package/dist/schema-management/validate-mutation-defs.js +63 -0
  65. package/dist/schema-management/validate-mutation-defs.js.map +1 -0
  66. package/dist/sql-queries/sql-queries.d.ts +4 -4
  67. package/dist/sql-queries/sql-queries.d.ts.map +1 -1
  68. package/dist/sql-queries/sql-queries.js +7 -8
  69. package/dist/sql-queries/sql-queries.js.map +1 -1
  70. package/dist/sql-queries/sql-query-builder.d.ts +1 -1
  71. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  72. package/dist/sync/index.d.ts +2 -0
  73. package/dist/sync/index.d.ts.map +1 -0
  74. package/dist/sync/index.js +2 -0
  75. package/dist/sync/index.js.map +1 -0
  76. package/dist/sync/sync.d.ts +25 -0
  77. package/dist/sync/sync.d.ts.map +1 -0
  78. package/dist/sync/sync.js +8 -0
  79. package/dist/sync/sync.js.map +1 -0
  80. package/package.json +6 -6
  81. package/src/__tests__/fixture.ts +23 -0
  82. package/src/adapter-types.ts +104 -0
  83. package/src/ambient.d.ts +3 -0
  84. package/src/derived-mutations.test.ts +100 -0
  85. package/src/derived-mutations.ts +126 -0
  86. package/src/index.ts +6 -2
  87. package/src/init-singleton-tables.ts +2 -2
  88. package/src/query-info.ts +104 -0
  89. package/src/rehydrate-from-mutationlog.ts +34 -20
  90. package/src/schema/index.ts +67 -39
  91. package/src/schema/mutations.ts +28 -9
  92. package/src/schema/parse-utils.ts +3 -3
  93. package/src/schema/system-tables.ts +44 -3
  94. package/src/schema/table-def.ts +64 -18
  95. package/src/schema-management/common.ts +29 -0
  96. package/src/{migrations.ts → schema-management/migrations.ts} +44 -57
  97. package/src/schema-management/validate-mutation-defs.ts +108 -0
  98. package/src/sql-queries/sql-queries.ts +8 -9
  99. package/src/sync/index.ts +1 -0
  100. package/src/sync/sync.ts +14 -0
  101. package/dist/database.d.ts +0 -50
  102. package/dist/database.d.ts.map +0 -1
  103. package/dist/database.js +0 -2
  104. package/dist/database.js.map +0 -1
  105. package/dist/migrations.d.ts +0 -16
  106. package/dist/migrations.d.ts.map +0 -1
  107. package/dist/migrations.js.map +0 -1
  108. package/src/database.ts +0 -60
@@ -8,7 +8,7 @@ import { type FromColumns, type FromTable, getDefaultValuesDecoded, type TableDe
8
8
  export const many = <TTableDef extends TableDef>(
9
9
  table: TTableDef,
10
10
  ): ((rawRows: ReadonlyArray<any>) => ReadonlyArray<FromTable.RowDecoded<TTableDef>>) => {
11
- return Schema.decodeSync(Schema.array(table.schema)) as TODO
11
+ return Schema.decodeSync(Schema.Array(table.schema)) as TODO
12
12
  }
13
13
 
14
14
  export const first =
@@ -17,7 +17,7 @@ export const first =
17
17
  fallback?: FromColumns.InsertRowDecoded<TTableDef['sqliteDef']['columns']>,
18
18
  ) =>
19
19
  (rawRows: ReadonlyArray<any>) => {
20
- const rows = Schema.decodeSync(Schema.array(table.schema))(rawRows)
20
+ const rows = Schema.decodeSync(Schema.Array(table.schema))(rawRows)
21
21
 
22
22
  if (rows.length === 0) {
23
23
  const schemaDefaultValues = getDefaultValuesDecoded(table)
@@ -31,7 +31,7 @@ export const first =
31
31
  if (defaultValuesResult._tag === 'Right') {
32
32
  return defaultValuesResult.right
33
33
  } else {
34
- console.error('decode error', TreeFormatter.formatError(defaultValuesResult.left))
34
+ console.error('decode error', TreeFormatter.formatErrorSync(defaultValuesResult.left))
35
35
  return shouldNeverHappen(
36
36
  `Expected query (for table ${table.sqliteDef.name}) to return at least one result but found none. Also can't fallback to default values as some were not provided.`,
37
37
  )
@@ -1,12 +1,14 @@
1
- // import { Schema as __Schema } from '@livestore/utils/effect'
1
+ import { Schema } from '@livestore/utils/effect'
2
2
  import { type SqliteAst as __SqliteAst, SqliteDsl } from 'effect-db-schema'
3
3
 
4
4
  import type { FromTable } from './table-def.js'
5
5
  import { table } from './table-def.js'
6
6
 
7
+ /// App DB
8
+
7
9
  export const SCHEMA_META_TABLE = '__livestore_schema'
8
10
 
9
- const schemaMetaTable = table(
11
+ export const schemaMetaTable = table(
10
12
  SCHEMA_META_TABLE,
11
13
  {
12
14
  tableName: SqliteDsl.text({ primaryKey: true }),
@@ -19,4 +21,43 @@ const schemaMetaTable = table(
19
21
 
20
22
  export type SchemaMetaRow = FromTable.RowDecoded<typeof schemaMetaTable>
21
23
 
22
- export const systemTables = [schemaMetaTable]
24
+ export const SCHEMA_MUTATIONS_META_TABLE = '__livestore_schema_mutations'
25
+
26
+ export const schemaMutationsMetaTable = table(
27
+ SCHEMA_MUTATIONS_META_TABLE,
28
+ {
29
+ mutationName: SqliteDsl.text({ primaryKey: true }),
30
+ schemaHash: SqliteDsl.integer({ nullable: false }),
31
+ /** ISO date format */
32
+ updatedAt: SqliteDsl.text({ nullable: false }),
33
+ },
34
+ { disableAutomaticIdColumn: true },
35
+ )
36
+
37
+ export type SchemaMutationsMetaRow = FromTable.RowDecoded<typeof schemaMutationsMetaTable>
38
+
39
+ export const systemTables = [schemaMetaTable, schemaMutationsMetaTable]
40
+
41
+ /// Mutation log DB
42
+
43
+ export const SyncStatus = Schema.Literal('synced', 'pending', 'error')
44
+ export type SyncStatus = typeof SyncStatus.Type
45
+
46
+ export const MUTATION_LOG_META_TABLE = 'mutation_log'
47
+
48
+ export const mutationLogMetaTable = table(
49
+ MUTATION_LOG_META_TABLE,
50
+ {
51
+ // TODO add parent ids (see https://vlcn.io/blog/crdt-substrate)
52
+ id: SqliteDsl.text({ primaryKey: true }),
53
+ mutation: SqliteDsl.text({ nullable: false }),
54
+ argsJson: SqliteDsl.text({ nullable: false, schema: Schema.parseJson(Schema.Any) }),
55
+ schemaHash: SqliteDsl.integer({ nullable: false }),
56
+ /** ISO date format */
57
+ createdAt: SqliteDsl.text({ nullable: false }),
58
+ syncStatus: SqliteDsl.text({ schema: SyncStatus }),
59
+ },
60
+ { disableAutomaticIdColumn: true },
61
+ )
62
+
63
+ export type MutationLogMetaRow = FromTable.RowDecoded<typeof mutationLogMetaTable>
@@ -3,6 +3,9 @@ import { pipe, ReadonlyRecord, Schema } from '@livestore/utils/effect'
3
3
  import type { Nullable, PrettifyFlat } from 'effect-db-schema'
4
4
  import { SqliteDsl } from 'effect-db-schema'
5
5
 
6
+ import type { DerivedMutationHelperFns } from '../derived-mutations.js'
7
+ import { makeDerivedMutationDefsForTable } from '../derived-mutations.js'
8
+
6
9
  export const { blob, boolean, column, datetime, integer, isColumnDefinition, json, real, text } = SqliteDsl
7
10
 
8
11
  export { type SqliteDsl } from 'effect-db-schema'
@@ -53,26 +56,42 @@ export type TableDef<
53
56
  >,
54
57
  > = {
55
58
  sqliteDef: TSqliteDef
59
+ // TODO move this into options (for now it's duplicated)
56
60
  isSingleColumn: TIsSingleColumn
57
61
  options: TOptions
58
62
  schema: TSchema
59
- }
63
+ } & (TOptions['deriveMutations'] extends true ? DerivedMutationHelperFns<TSqliteDef['columns'], TOptions> : {})
60
64
 
61
- export type TableOptionsInput = Partial<TableOptions & { indexes: SqliteDsl.Index[] }>
65
+ export type TableOptionsInput = Partial<Omit<TableOptions, 'isSingleColumn'> & { indexes: SqliteDsl.Index[] }>
62
66
 
63
67
  export type TableOptions = {
64
68
  /**
65
69
  * Setting this to true will have the following consequences:
66
70
  * - An `id` column will be added with `primaryKey: true` and `"singleton"` as default value and only allowed value
67
- * - LiveStore will automatically create the singleton row when the table is created
71
+ * - LiveStore will automatically create the singleton row when booting up
68
72
  * - LiveStore will fail if there is already a column defined with `primaryKey: true`
69
73
  *
70
74
  * @default false
71
75
  */
72
76
  isSingleton: boolean
73
- // TODO
77
+ // TODO remove
74
78
  dynamicRegistration: boolean
75
79
  disableAutomaticIdColumn: boolean
80
+ /**
81
+ * Setting this to true will automatically derive insert, update and delete mutations for this table. Example:
82
+ *
83
+ * ```ts
84
+ * const todos = table('todos', { ... }, { deriveMutations: true })
85
+ * todos.insert({ id: '1', text: 'Hello' })
86
+ * ```
87
+ *
88
+ * This is also a prerequisite for using the `useRow`, `useAtom` and `rowQuery` APIs.
89
+ *
90
+ * Important: When using this option, make sure you're following the "Rules of mutations" for the table schema.
91
+ */
92
+ deriveMutations: boolean
93
+ /** Derived based on whether the table definition has one or more columns (besides the `id` column) */
94
+ isSingleColumn: boolean
76
95
  }
77
96
 
78
97
  export const table = <
@@ -82,17 +101,19 @@ export const table = <
82
101
  >(
83
102
  name: TName,
84
103
  columnOrColumns: TColumns,
85
- // type?: TStateType,
86
104
  options?: TOptionsInput,
87
105
  ): TableDef<
88
106
  SqliteDsl.TableDefinition<
89
107
  TName,
90
108
  PrettifyFlat<
91
- WithId<TColumns extends SqliteDsl.Columns ? TColumns : { value: TColumns }, WithDefaults<TOptionsInput>>
109
+ WithId<
110
+ TColumns extends SqliteDsl.Columns ? TColumns : { value: TColumns },
111
+ WithDefaults<TOptionsInput, SqliteDsl.IsSingleColumn<TColumns>>
112
+ >
92
113
  >
93
114
  >,
94
- TColumns extends SqliteDsl.ColumnDefinition<any, any> ? true : false,
95
- WithDefaults<TOptionsInput>
115
+ SqliteDsl.IsSingleColumn<TColumns>,
116
+ WithDefaults<TOptionsInput, SqliteDsl.IsSingleColumn<TColumns>>
96
117
  > => {
97
118
  const tablePath = name
98
119
 
@@ -100,6 +121,8 @@ export const table = <
100
121
  isSingleton: options?.isSingleton ?? false,
101
122
  dynamicRegistration: options?.dynamicRegistration ?? false,
102
123
  disableAutomaticIdColumn: options?.disableAutomaticIdColumn ?? false,
124
+ deriveMutations: options?.deriveMutations ?? false,
125
+ isSingleColumn: SqliteDsl.isColumnDefinition(columnOrColumns) === true,
103
126
  }
104
127
 
105
128
  const columns = (
@@ -114,7 +137,7 @@ export const table = <
114
137
  }
115
138
  } else if (columns.id === undefined && ReadonlyRecord.some(columns, (_) => _.primaryKey === true) === false) {
116
139
  if (options_.isSingleton) {
117
- columns.id = SqliteDsl.text({ schema: Schema.literal('singleton'), primaryKey: true, default: 'singleton' })
140
+ columns.id = SqliteDsl.text({ schema: Schema.Literal('singleton'), primaryKey: true, default: 'singleton' })
118
141
  } else {
119
142
  columns.id = SqliteDsl.text({ primaryKey: true })
120
143
  }
@@ -122,6 +145,7 @@ export const table = <
122
145
 
123
146
  const sqliteDef = SqliteDsl.table(tablePath, columns, options?.indexes ?? [])
124
147
 
148
+ // TODO also enforce this on the type level
125
149
  if (options_.isSingleton) {
126
150
  for (const column of sqliteDef.ast.columns) {
127
151
  if (column.nullable === false && column.default._tag === 'None') {
@@ -137,18 +161,38 @@ export const table = <
137
161
  const schema = SqliteDsl.structSchemaForTable(sqliteDef)
138
162
  const tableDef = { sqliteDef, isSingleColumn, options: options_, schema } satisfies TableDef
139
163
 
140
- // if (dynamicallyRegisteredTables.has(tablePath)) {
141
- // if (SqliteAst.hash(dynamicallyRegisteredTables.get(tablePath)!.sqliteDef.ast) !== SqliteAst.hash(sqliteDef.ast)) {
142
- // console.error('previous tableDef', dynamicallyRegisteredTables.get(tablePath), 'new tableDef', sqliteDef.ast)
143
- // shouldNeverHappen(`Table with name "${name}" was already previously defined with a different definition`)
144
- // }
145
- // } else {
146
- // dynamicallyRegisteredTables.set(tablePath, tableDef)
147
- // }
164
+ if (tableHasDerivedMutations(tableDef)) {
165
+ const derivedMutationDefs = makeDerivedMutationDefsForTable(tableDef)
166
+
167
+ tableDef.insert = (valuesOrValue: any) => {
168
+ if (isSingleColumn && options_.isSingleton) {
169
+ return derivedMutationDefs.insert({ id: 'singleton', value: { value: valuesOrValue } })
170
+ } else {
171
+ return derivedMutationDefs.insert(valuesOrValue as any)
172
+ }
173
+ }
174
+
175
+ tableDef.update = (argsOrValues: any) => {
176
+ if (isSingleColumn && options_.isSingleton) {
177
+ return derivedMutationDefs.update({ where: { id: 'singleton' }, values: { value: argsOrValues } as any })
178
+ } else {
179
+ return derivedMutationDefs.update(argsOrValues as any)
180
+ }
181
+ }
182
+
183
+ tableDef.delete = (args: any) => derivedMutationDefs.delete(args)
184
+ }
148
185
 
149
186
  return tableDef as any
150
187
  }
151
188
 
189
+ export const tableHasDerivedMutations = <TTableDef extends TableDef>(
190
+ tableDef: TTableDef,
191
+ ): tableDef is TTableDef & {
192
+ options: { deriveMutations: true }
193
+ } & DerivedMutationHelperFns<TTableDef['sqliteDef']['columns'], TTableDef['options']> =>
194
+ tableDef.options.deriveMutations === true
195
+
152
196
  export const tableIsSingleton = <TTableDef extends TableDef>(
153
197
  tableDef: TTableDef,
154
198
  ): tableDef is TTableDef & { options: { isSingleton: true } } => tableDef.options.isSingleton === true
@@ -210,10 +254,12 @@ type WithId<TColumns extends SqliteDsl.Columns, TOptions extends TableOptions> =
210
254
  id: SqliteDsl.ColumnDefinition<string, string>
211
255
  })
212
256
 
213
- type WithDefaults<TOptionsInput extends TableOptionsInput> = {
257
+ type WithDefaults<TOptionsInput extends TableOptionsInput, TIsSingleColumn extends boolean> = {
214
258
  isSingleton: TOptionsInput['isSingleton'] extends true ? true : false
215
259
  dynamicRegistration: TOptionsInput['dynamicRegistration'] extends true ? true : false
216
260
  disableAutomaticIdColumn: TOptionsInput['disableAutomaticIdColumn'] extends true ? true : false
261
+ deriveMutations: TOptionsInput['deriveMutations'] extends true ? true : false
262
+ isSingleColumn: TIsSingleColumn
217
263
  }
218
264
 
219
265
  export namespace FromTable {
@@ -0,0 +1,29 @@
1
+ import type { InMemoryDatabase } from '../adapter-types.js'
2
+ import type { ParamsObject } from '../util.js'
3
+ import { prepareBindValues } from '../util.js'
4
+
5
+ // TODO bring back statement caching
6
+ // will require proper scope-aware cleanup etc (for testing and apps with multiple LiveStore instances)
7
+ // const cachedStmts = new Map<string, PreparedStatement>()
8
+
9
+ export const dbExecute = (db: InMemoryDatabase, queryStr: string, bindValues?: ParamsObject) => {
10
+ // let stmt = cachedStmts.get(queryStr)
11
+ // if (!stmt) {
12
+ const stmt = db.prepare(queryStr)
13
+ // cachedStmts.set(queryStr, stmt)
14
+ // }
15
+
16
+ const preparedBindValues = bindValues ? prepareBindValues(bindValues, queryStr) : undefined
17
+
18
+ stmt.execute(preparedBindValues)
19
+ }
20
+
21
+ export const dbSelect = <T>(db: InMemoryDatabase, queryStr: string, bindValues?: ParamsObject) => {
22
+ // let stmt = cachedStmts.get(queryStr)
23
+ // if (!stmt) {
24
+ const stmt = db.prepare(queryStr)
25
+ // cachedStmts.set(queryStr, stmt)
26
+ // }
27
+
28
+ return stmt.select<T>(bindValues ? prepareBindValues(bindValues, queryStr) : undefined)
29
+ }
@@ -1,58 +1,36 @@
1
- import { memoize } from '@livestore/utils'
1
+ import { memoizeByStringifyArgs } from '@livestore/utils'
2
2
  import { Schema as EffectSchema } from '@livestore/utils/effect'
3
- import type * as otel from '@opentelemetry/api'
3
+ import * as otel from '@opentelemetry/api'
4
4
  import { SqliteAst, SqliteDsl } from 'effect-db-schema'
5
5
 
6
- import type { MainDatabase } from './database.js'
7
- import type { LiveStoreSchema } from './schema/index.js'
8
- import type { SchemaMetaRow } from './schema/system-tables.js'
9
- import { SCHEMA_META_TABLE, systemTables } from './schema/system-tables.js'
10
- import type { ParamsObject } from './util.js'
11
- import { prepareBindValues, sql } from './util.js'
6
+ import type { InMemoryDatabase } from '../adapter-types.js'
7
+ import type { LiveStoreSchema } from '../schema/index.js'
8
+ import type { SchemaMetaRow } from '../schema/system-tables.js'
9
+ import { SCHEMA_META_TABLE, schemaMetaTable, systemTables } from '../schema/system-tables.js'
10
+ import { sql } from '../util.js'
11
+ import { dbExecute, dbSelect } from './common.js'
12
+ import { makeSchemaManager, validateSchema } from './validate-mutation-defs.js'
12
13
 
13
- const getMemoizedTimestamp = memoize(() => new Date().toISOString())
14
-
15
- // TODO bring back statement caching
16
- // will require proper scope-aware cleanup etc (for testing and apps with multiple LiveStore instances)
17
- // const cachedStmts = new Map<string, PreparedStatement>()
18
-
19
- const dbExecute = (db: MainDatabase, queryStr: string, bindValues?: ParamsObject) => {
20
- // let stmt = cachedStmts.get(queryStr)
21
- // if (!stmt) {
22
- const stmt = db.prepare(queryStr)
23
- // cachedStmts.set(queryStr, stmt)
24
- // }
25
-
26
- const preparedBindValues = bindValues ? prepareBindValues(bindValues, queryStr) : undefined
27
-
28
- stmt.execute(preparedBindValues)
29
- }
30
-
31
- const dbSelect = <T>(db: MainDatabase, queryStr: string, bindValues?: ParamsObject) => {
32
- // let stmt = cachedStmts.get(queryStr)
33
- // if (!stmt) {
34
- const stmt = db.prepare(queryStr)
35
- // cachedStmts.set(queryStr, stmt)
36
- // }
37
-
38
- return stmt.select<T>(bindValues ? prepareBindValues(bindValues, queryStr) : undefined)
39
- }
14
+ const getMemoizedTimestamp = memoizeByStringifyArgs(() => new Date().toISOString())
40
15
 
41
16
  // TODO more graceful DB migration (e.g. backup DB before destructive migrations)
42
17
  export const migrateDb = ({
43
18
  db,
44
- otelContext,
19
+ otelContext = otel.context.active(),
45
20
  schema,
46
21
  }: {
47
- db: MainDatabase
48
- otelContext: otel.Context
22
+ db: InMemoryDatabase
23
+ otelContext?: otel.Context
49
24
  schema: LiveStoreSchema
50
25
  }) => {
51
- dbExecute(
26
+ migrateTable({
52
27
  db,
53
- // TODO use schema migration definition from schema.ts instead
54
- sql`create table if not exists ${SCHEMA_META_TABLE} (tableName text primary key, schemaHash text, updatedAt text);`,
55
- )
28
+ otelContext,
29
+ tableAst: schemaMetaTable.sqliteDef.ast,
30
+ behaviour: 'create-if-not-exists',
31
+ })
32
+
33
+ validateSchema(schema, makeSchemaManager(db))
56
34
 
57
35
  const schemaMetaRows = dbSelect<SchemaMetaRow>(db, sql`SELECT * FROM ${SCHEMA_META_TABLE}`)
58
36
 
@@ -72,7 +50,6 @@ export const migrateDb = ({
72
50
  const dbSchemaHash = dbSchemaHashByTable[tableName]
73
51
  const schemaHash = SqliteAst.hash(tableAst)
74
52
 
75
- // @ts-expect-error TODO fix typing
76
53
  const skipMigrations = import.meta.env.VITE_LIVESTORE_SKIP_MIGRATIONS !== undefined
77
54
 
78
55
  if (schemaHash !== dbSchemaHash && skipMigrations === false) {
@@ -80,7 +57,7 @@ export const migrateDb = ({
80
57
  `Schema hash mismatch for table '${tableName}' (DB: ${dbSchemaHash}, expected: ${schemaHash}), migrating table...`,
81
58
  )
82
59
 
83
- migrateTable({ db, tableAst, otelContext, schemaHash })
60
+ migrateTable({ db, tableAst, otelContext, schemaHash, behaviour: 'drop-and-recreate' })
84
61
  }
85
62
  }
86
63
  }
@@ -89,35 +66,45 @@ export const migrateTable = ({
89
66
  db,
90
67
  tableAst,
91
68
  // otelContext,
92
- schemaHash,
69
+ schemaHash = SqliteAst.hash(tableAst),
70
+ behaviour,
71
+ skipMetaTable = false,
93
72
  }: {
94
- db: MainDatabase
73
+ db: InMemoryDatabase
95
74
  tableAst: SqliteAst.Table
96
- otelContext: otel.Context
97
- schemaHash: number
75
+ otelContext?: otel.Context
76
+ schemaHash?: number
77
+ behaviour: 'drop-and-recreate' | 'create-if-not-exists'
78
+ skipMetaTable?: boolean
98
79
  }) => {
99
80
  console.log(`Migrating table '${tableAst.name}'...`)
100
81
  const tableName = tableAst.name
101
82
  const columnSpec = makeColumnSpec(tableAst)
102
83
 
103
- // TODO need to possibly handle cascading deletes due to foreign keys
104
- dbExecute(db, sql`drop table if exists ${tableName}`)
105
- dbExecute(db, sql`create table if not exists ${tableName} (${columnSpec})`)
84
+ if (behaviour === 'drop-and-recreate') {
85
+ // TODO need to possibly handle cascading deletes due to foreign keys
86
+ dbExecute(db, sql`drop table if exists ${tableName}`)
87
+ dbExecute(db, sql`create table if not exists ${tableName} (${columnSpec}) strict`)
88
+ } else if (behaviour === 'create-if-not-exists') {
89
+ dbExecute(db, sql`create table if not exists ${tableName} (${columnSpec}) strict`)
90
+ }
106
91
 
107
92
  for (const index of tableAst.indexes) {
108
93
  dbExecute(db, createIndexFromDefinition(tableName, index))
109
94
  }
110
95
 
111
- const updatedAt = getMemoizedTimestamp()
96
+ if (skipMetaTable !== true) {
97
+ const updatedAt = getMemoizedTimestamp()
112
98
 
113
- dbExecute(
114
- db,
115
- sql`
99
+ dbExecute(
100
+ db,
101
+ sql`
116
102
  INSERT INTO ${SCHEMA_META_TABLE} (tableName, schemaHash, updatedAt) VALUES ($tableName, $schemaHash, $updatedAt)
117
103
  ON CONFLICT (tableName) DO UPDATE SET schemaHash = $schemaHash, updatedAt = $updatedAt;
118
104
  `,
119
- { tableName, schemaHash, updatedAt },
120
- )
105
+ { tableName, schemaHash, updatedAt },
106
+ )
107
+ }
121
108
  }
122
109
 
123
110
  const createIndexFromDefinition = (tableName: string, index: SqliteAst.Index) => {
@@ -0,0 +1,108 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
2
+ import { Schema } from '@livestore/utils/effect'
3
+ import * as otel from '@opentelemetry/api'
4
+
5
+ import type { InMemoryDatabase } from '../adapter-types.js'
6
+ import type { LiveStoreSchema } from '../schema/index.js'
7
+ import type { MutationDef } from '../schema/mutations.js'
8
+ import type { SchemaMutationsMetaRow } from '../schema/system-tables.js'
9
+ import { SCHEMA_MUTATIONS_META_TABLE, schemaMutationsMetaTable } from '../schema/system-tables.js'
10
+ import { sql } from '../util.js'
11
+ import { dbExecute, dbSelect } from './common.js'
12
+ import { migrateTable } from './migrations.js'
13
+
14
+ export const validateSchema = (schema: LiveStoreSchema, schemaManager: SchemaManager) => {
15
+ // Validate mutation definitions
16
+ const registeredMutationDefInfos = schemaManager.getMutationDefInfos()
17
+
18
+ const missingMutationDefs = registeredMutationDefInfos.filter(
19
+ (registeredMutationDefInfo) => !schema.mutations.has(registeredMutationDefInfo.mutationName),
20
+ )
21
+
22
+ if (missingMutationDefs.length > 0) {
23
+ shouldNeverHappen(
24
+ `Missing mutation definitions: ${missingMutationDefs.map((info) => info.mutationName).join(', ')}`,
25
+ )
26
+ }
27
+
28
+ for (const [, mutationDef] of schema.mutations) {
29
+ const registeredMutationDefInfo = registeredMutationDefInfos.find((info) => info.mutationName === mutationDef.name)
30
+
31
+ validateMutationDef(mutationDef, schemaManager, registeredMutationDefInfo)
32
+ }
33
+
34
+ // Validate table schemas
35
+ }
36
+
37
+ export const validateMutationDef = (
38
+ mutationDef: MutationDef.Any,
39
+ schemaManager: SchemaManager,
40
+ registeredMutationDefInfo: MutationDefInfo | undefined,
41
+ ) => {
42
+ const schemaHash = Schema.hash(mutationDef.schema)
43
+
44
+ if (registeredMutationDefInfo === undefined) {
45
+ schemaManager.setMutationDefInfo({
46
+ schemaHash,
47
+ mutationName: mutationDef.name,
48
+ })
49
+
50
+ return
51
+ }
52
+
53
+ if (schemaHash === registeredMutationDefInfo.schemaHash) return
54
+
55
+ // TODO bring back some form of schema compatibility check (see https://github.com/livestorejs/livestore/issues/69)
56
+ // const newSchemaIsCompatibleWithOldSchema = Schema.isSubType(jsonSchemaDefFromMgmtStore, mutationDef.schema)
57
+
58
+ // if (!newSchemaIsCompatibleWithOldSchema) {
59
+ // shouldNeverHappen(`Schema for mutation ${mutationDef.name} has changed in an incompatible way`)
60
+ // }
61
+
62
+ schemaManager.setMutationDefInfo({
63
+ schemaHash,
64
+ mutationName: mutationDef.name,
65
+ })
66
+ }
67
+
68
+ interface SchemaManager {
69
+ getMutationDefInfos: () => ReadonlyArray<MutationDefInfo>
70
+
71
+ setMutationDefInfo: (mutationDefInfo: MutationDefInfo) => void
72
+ }
73
+
74
+ type MutationDefInfo = {
75
+ mutationName: string
76
+ schemaHash: number
77
+ }
78
+
79
+ export const makeSchemaManager = (db: InMemoryDatabase): SchemaManager => {
80
+ migrateTable({
81
+ db,
82
+ otelContext: otel.context.active(),
83
+ tableAst: schemaMutationsMetaTable.sqliteDef.ast,
84
+ behaviour: 'create-if-not-exists',
85
+ })
86
+
87
+ return {
88
+ getMutationDefInfos: () => {
89
+ const schemaMutationsMetaRows = dbSelect<SchemaMutationsMetaRow>(
90
+ db,
91
+ sql`SELECT * FROM ${SCHEMA_MUTATIONS_META_TABLE}`,
92
+ )
93
+
94
+ return schemaMutationsMetaRows
95
+ },
96
+ setMutationDefInfo: (info) => {
97
+ dbExecute(
98
+ db,
99
+ sql`INSERT OR REPLACE INTO ${SCHEMA_MUTATIONS_META_TABLE} (mutationName, schemaHash, updatedAt) VALUES ($mutationName, $schemaHash, $updatedAt)`,
100
+ {
101
+ mutationName: info.mutationName,
102
+ schemaHash: info.schemaHash,
103
+ updatedAt: new Date().toISOString(),
104
+ },
105
+ )
106
+ },
107
+ }
108
+ }
@@ -1,3 +1,4 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
1
2
  import { pipe, ReadonlyArray, Schema, TreeFormatter } from '@livestore/utils/effect'
2
3
  import type { SqliteDsl } from 'effect-db-schema'
3
4
 
@@ -55,7 +56,7 @@ export const insertRow = <TColumns extends SqliteDsl.Columns>({
55
56
  tableName: string
56
57
  columns: TColumns
57
58
  values: ClientTypes.DecodedValuesForColumns<TColumns>
58
- options: { orReplace: boolean }
59
+ options?: { orReplace: boolean }
59
60
  }): [string, BindValues] => {
60
61
  const keysStr = Object.keys(values).join(', ')
61
62
  const valuesStr = Object.keys(values)
@@ -63,7 +64,7 @@ export const insertRow = <TColumns extends SqliteDsl.Columns>({
63
64
  .join(', ')
64
65
 
65
66
  return [
66
- sql`INSERT ${options.orReplace ? 'OR REPLACE' : ''} INTO ${tableName} (${keysStr}) VALUES (${valuesStr})`,
67
+ sql`INSERT ${options.orReplace ? 'OR REPLACE ' : ''}INTO ${tableName} (${keysStr}) VALUES (${valuesStr})`,
67
68
  makeBindValues({ columns, values }),
68
69
  ]
69
70
  }
@@ -263,7 +264,7 @@ export const makeBindValues = <TColumns extends SqliteDsl.Columns, TKeys extends
263
264
  if (columnDef.nullable === true && (value === null || value === undefined)) return null
264
265
  const res = Schema.encodeEither(columnDef.schema)(value)
265
266
  if (res._tag === 'Left') {
266
- const parseErrorStr = TreeFormatter.formatError(res.left)
267
+ const parseErrorStr = TreeFormatter.formatErrorSync(res.left)
267
268
  const expectedSchemaStr = String(columnDef.schema.ast)
268
269
 
269
270
  console.error(
@@ -292,26 +293,24 @@ Value:`,
292
293
  // NOTE null/undefined values are handled via explicit SQL syntax and don't need to be provided as bind values
293
294
  .filter(([, value]) => skipNil !== true || (value !== null && value !== undefined))
294
295
  .flatMap(([columnName, value]: [string, any]) => {
296
+ const codec = codecMap[columnName] ?? shouldNeverHappen(`No codec found for column "${columnName}"`)
295
297
  // remap complex where-values with `op`
296
298
  if (typeof value === 'object' && value !== null && 'op' in value) {
297
299
  switch (value.op) {
298
300
  case 'in': {
299
- return value.val.map((value: any, i: number) => [
300
- `${variablePrefix}${columnName}_${i}`,
301
- codecMap[columnName]!(value),
302
- ])
301
+ return value.val.map((value: any, i: number) => [`${variablePrefix}${columnName}_${i}`, codec(value)])
303
302
  }
304
303
  case '=':
305
304
  case '>':
306
305
  case '<': {
307
- return [[`${variablePrefix}${columnName}`, codecMap[columnName]!(value.val)]]
306
+ return [[`${variablePrefix}${columnName}`, codec(value.val)]]
308
307
  }
309
308
  default: {
310
309
  throw new Error(`Unknown op: ${value.op}`)
311
310
  }
312
311
  }
313
312
  } else {
314
- return [[`${variablePrefix}${columnName}`, codecMap[columnName]!(value)]]
313
+ return [[`${variablePrefix}${columnName}`, codec(value)]]
315
314
  }
316
315
  }),
317
316
  Object.fromEntries,
@@ -0,0 +1 @@
1
+ export * from './sync.js'
@@ -0,0 +1,14 @@
1
+ import { type Effect, Schema, type Stream, type SubscriptionRef } from '@livestore/utils/effect'
2
+
3
+ import type { MutationEvent } from '../schema/mutations.js'
4
+
5
+ export type SyncImpl = {
6
+ pull: (cursor: string | undefined) => Stream.Stream<MutationEvent.AnyEncoded, IsOfflineError | InvalidPullError>
7
+ push: (mutationEvent: MutationEvent.AnyEncoded) => Effect.Effect<void, IsOfflineError | InvalidPushError>
8
+ pushes: Stream.Stream<MutationEvent.AnyEncoded>
9
+ isConnected: SubscriptionRef.SubscriptionRef<boolean>
10
+ }
11
+
12
+ export class IsOfflineError extends Schema.TaggedError<IsOfflineError>()('IsOfflineError', {}) {}
13
+ export class InvalidPushError extends Schema.TaggedError<InvalidPushError>()('InvalidPushError', {}) {}
14
+ export class InvalidPullError extends Schema.TaggedError<InvalidPullError>()('InvalidPullError', {}) {}