@livestore/common 0.0.46-dev.4 → 0.0.47-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 (42) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/database.d.ts +21 -3
  3. package/dist/database.d.ts.map +1 -1
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/init-singleton-tables.d.ts +4 -0
  9. package/dist/init-singleton-tables.d.ts.map +1 -0
  10. package/dist/init-singleton-tables.js +16 -0
  11. package/dist/init-singleton-tables.js.map +1 -0
  12. package/dist/migrations.d.ts +16 -0
  13. package/dist/migrations.d.ts.map +1 -0
  14. package/dist/migrations.js +99 -0
  15. package/dist/migrations.js.map +1 -0
  16. package/dist/mutation.d.ts +11 -0
  17. package/dist/mutation.d.ts.map +1 -0
  18. package/dist/mutation.js +29 -0
  19. package/dist/mutation.js.map +1 -0
  20. package/dist/rehydrate-from-mutationlog.d.ts +8 -0
  21. package/dist/rehydrate-from-mutationlog.d.ts.map +1 -0
  22. package/dist/rehydrate-from-mutationlog.js +51 -0
  23. package/dist/rehydrate-from-mutationlog.js.map +1 -0
  24. package/dist/schema/index.d.ts +2 -1
  25. package/dist/schema/index.d.ts.map +1 -1
  26. package/dist/schema/index.js +5 -0
  27. package/dist/schema/index.js.map +1 -1
  28. package/dist/schema/mutations.d.ts +9 -11
  29. package/dist/schema/mutations.d.ts.map +1 -1
  30. package/dist/schema/mutations.js.map +1 -1
  31. package/dist/schema/table-def.d.ts +1 -1
  32. package/dist/schema/table-def.d.ts.map +1 -1
  33. package/package.json +4 -4
  34. package/src/database.ts +26 -3
  35. package/src/index.ts +4 -0
  36. package/src/init-singleton-tables.ts +24 -0
  37. package/src/migrations.ts +155 -0
  38. package/src/mutation.ts +50 -0
  39. package/src/rehydrate-from-mutationlog.ts +77 -0
  40. package/src/schema/index.ts +7 -1
  41. package/src/schema/mutations.ts +16 -11
  42. package/src/schema/table-def.ts +10 -8
package/src/index.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  export * from './util.js'
2
2
  export * from './database.js'
3
+ export * from './migrations.js'
4
+ export * from './mutation.js'
5
+ export * from './init-singleton-tables.js'
6
+ export * from './rehydrate-from-mutationlog.js'
@@ -0,0 +1,24 @@
1
+ import type { MainDatabase } from './database.js'
2
+ import type { LiveStoreSchema } from './schema/index.js'
3
+ import { DbSchema } from './schema/index.js'
4
+ import { prepareBindValues, sql } from './util.js'
5
+
6
+ export const initializeSingletonTables = (schema: LiveStoreSchema, db: MainDatabase) => {
7
+ for (const [, tableDef] of schema.tables) {
8
+ if (tableDef.options.isSingleton) {
9
+ const defaultValues = DbSchema.getDefaultValuesEncoded(tableDef, undefined)
10
+
11
+ const defaultColumnNames = [...Object.keys(defaultValues), 'id']
12
+ const columnValues = defaultColumnNames.map((name) => `$${name}`).join(', ')
13
+
14
+ const tableName = tableDef.sqliteDef.name
15
+ const insertQuery = sql`insert into ${tableName} (${defaultColumnNames.join(
16
+ ', ',
17
+ )}) select ${columnValues} where not exists(select 1 from ${tableName} where id = 'singleton')`
18
+
19
+ const bindValues = prepareBindValues({ ...defaultValues, id: 'singleton' }, insertQuery)
20
+
21
+ db.execute(insertQuery, bindValues)
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,155 @@
1
+ import { memoize } from '@livestore/utils'
2
+ import { Schema as EffectSchema } from '@livestore/utils/effect'
3
+ import type * as otel from '@opentelemetry/api'
4
+ import { SqliteAst, SqliteDsl } from 'effect-db-schema'
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'
12
+
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
+ }
40
+
41
+ // TODO more graceful DB migration (e.g. backup DB before destructive migrations)
42
+ export const migrateDb = ({
43
+ db,
44
+ otelContext,
45
+ schema,
46
+ }: {
47
+ db: MainDatabase
48
+ otelContext: otel.Context
49
+ schema: LiveStoreSchema
50
+ }) => {
51
+ dbExecute(
52
+ 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
+ )
56
+
57
+ const schemaMetaRows = dbSelect<SchemaMetaRow>(db, sql`SELECT * FROM ${SCHEMA_META_TABLE}`)
58
+
59
+ const dbSchemaHashByTable = Object.fromEntries(
60
+ schemaMetaRows.map(({ tableName, schemaHash }) => [tableName, schemaHash]),
61
+ )
62
+
63
+ const tableDefs = new Set([
64
+ // NOTE it's important the `SCHEMA_META_TABLE` comes first since we're writing to it below
65
+ ...systemTables,
66
+ ...Array.from(schema.tables.values()).filter((_) => _.sqliteDef.name !== SCHEMA_META_TABLE),
67
+ ])
68
+
69
+ for (const tableDef of tableDefs) {
70
+ const tableAst = tableDef.sqliteDef.ast
71
+ const tableName = tableAst.name
72
+ const dbSchemaHash = dbSchemaHashByTable[tableName]
73
+ const schemaHash = SqliteAst.hash(tableAst)
74
+
75
+ // @ts-expect-error TODO fix typing
76
+ const skipMigrations = import.meta.env.VITE_LIVESTORE_SKIP_MIGRATIONS !== undefined
77
+
78
+ if (schemaHash !== dbSchemaHash && skipMigrations === false) {
79
+ console.log(
80
+ `Schema hash mismatch for table '${tableName}' (DB: ${dbSchemaHash}, expected: ${schemaHash}), migrating table...`,
81
+ )
82
+
83
+ migrateTable({ db, tableAst, otelContext, schemaHash })
84
+ }
85
+ }
86
+ }
87
+
88
+ export const migrateTable = ({
89
+ db,
90
+ tableAst,
91
+ // otelContext,
92
+ schemaHash,
93
+ }: {
94
+ db: MainDatabase
95
+ tableAst: SqliteAst.Table
96
+ otelContext: otel.Context
97
+ schemaHash: number
98
+ }) => {
99
+ console.log(`Migrating table '${tableAst.name}'...`)
100
+ const tableName = tableAst.name
101
+ const columnSpec = makeColumnSpec(tableAst)
102
+
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})`)
106
+
107
+ for (const index of tableAst.indexes) {
108
+ dbExecute(db, createIndexFromDefinition(tableName, index))
109
+ }
110
+
111
+ const updatedAt = getMemoizedTimestamp()
112
+
113
+ dbExecute(
114
+ db,
115
+ sql`
116
+ INSERT INTO ${SCHEMA_META_TABLE} (tableName, schemaHash, updatedAt) VALUES ($tableName, $schemaHash, $updatedAt)
117
+ ON CONFLICT (tableName) DO UPDATE SET schemaHash = $schemaHash, updatedAt = $updatedAt;
118
+ `,
119
+ { tableName, schemaHash, updatedAt },
120
+ )
121
+ }
122
+
123
+ const createIndexFromDefinition = (tableName: string, index: SqliteAst.Index) => {
124
+ const uniqueStr = index.unique ? 'UNIQUE' : ''
125
+ return sql`create ${uniqueStr} index ${index.name} on ${tableName} (${index.columns.join(', ')})`
126
+ }
127
+
128
+ const makeColumnSpec = (tableAst: SqliteAst.Table) => {
129
+ const primaryKeys = tableAst.columns.filter((_) => _.primaryKey).map((_) => _.name)
130
+ const columnDefStrs = tableAst.columns.map(toSqliteColumnSpec)
131
+ if (primaryKeys.length > 0) {
132
+ columnDefStrs.push(`PRIMARY KEY (${primaryKeys.join(', ')})`)
133
+ }
134
+
135
+ return columnDefStrs.join(', ')
136
+ }
137
+
138
+ /** NOTE primary keys are applied on a table level not on a column level to account for multi-column primary keys */
139
+ const toSqliteColumnSpec = (column: SqliteAst.Column) => {
140
+ const columnTypeStr = column.type._tag
141
+ const nullableStr = column.nullable === false ? 'not null' : ''
142
+ const defaultValueStr = (() => {
143
+ if (column.default._tag === 'None') return ''
144
+
145
+ if (SqliteDsl.isSqlDefaultValue(column.default.value)) return `default ${column.default.value.sql}`
146
+
147
+ const encodeValue = EffectSchema.encodeSync(column.schema)
148
+ const encodedDefaultValue = encodeValue(column.default.value)
149
+
150
+ if (columnTypeStr === 'text') return `default '${encodedDefaultValue}'`
151
+ return `default ${encodedDefaultValue}`
152
+ })()
153
+
154
+ return `${column.name} ${columnTypeStr} ${nullableStr} ${defaultValueStr}`
155
+ }
@@ -0,0 +1,50 @@
1
+ import { Schema } from '@livestore/utils/effect'
2
+
3
+ import type { MutationDef, MutationEvent } from './schema/mutations.js'
4
+ import type { PreparedBindValues } from './util.js'
5
+ import { prepareBindValues } from './util.js'
6
+
7
+ export const getExecArgsFromMutation = ({
8
+ mutationDef,
9
+ mutationEventDecoded,
10
+ }: {
11
+ mutationDef: MutationDef.Any
12
+ mutationEventDecoded: MutationEvent.Any
13
+ }): ReadonlyArray<{
14
+ statementSql: string
15
+ bindValues: PreparedBindValues
16
+ writeTables: ReadonlySet<string> | undefined
17
+ }> => {
18
+ let statementRes: ReadonlyArray<
19
+ string | { sql: string; bindValues: Record<string, unknown>; writeTables?: ReadonlySet<string> }
20
+ >
21
+
22
+ switch (typeof mutationDef.sql) {
23
+ case 'function': {
24
+ const res = mutationDef.sql(mutationEventDecoded.args)
25
+ statementRes = Array.isArray(res) ? res : [res]
26
+ break
27
+ }
28
+ case 'string': {
29
+ statementRes = [mutationDef.sql]
30
+ break
31
+ }
32
+ default: {
33
+ statementRes = mutationDef.sql
34
+ break
35
+ }
36
+ }
37
+
38
+ return statementRes.map((statementRes) => {
39
+ const statementSql = typeof statementRes === 'string' ? statementRes : statementRes.sql
40
+
41
+ const bindValues =
42
+ typeof statementRes === 'string'
43
+ ? Schema.encodeUnknownSync(mutationDef.schema)(mutationEventDecoded.args)
44
+ : statementRes.bindValues
45
+
46
+ const writeTables = typeof statementRes === 'string' ? undefined : statementRes.writeTables
47
+
48
+ return { statementSql, bindValues: prepareBindValues(bindValues ?? {}, statementSql), writeTables }
49
+ })
50
+ }
@@ -0,0 +1,77 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
2
+ import { Schema } from '@livestore/utils/effect'
3
+
4
+ import type { MainDatabase } from './database.js'
5
+ import { getExecArgsFromMutation } from './mutation.js'
6
+ import type { LiveStoreSchema } from './schema/index.js'
7
+
8
+ type MutationLogRow = {
9
+ id: string
10
+ mutation: string
11
+ args_json: string
12
+ schema_hash: number
13
+ created_at: string
14
+ }
15
+
16
+ export const rehydrateFromMutationLog = ({
17
+ logDb,
18
+ db,
19
+ schema,
20
+ }: {
21
+ logDb: MainDatabase
22
+ db: MainDatabase
23
+ schema: LiveStoreSchema
24
+ }) => {
25
+ try {
26
+ const stmt = logDb.prepare('SELECT * FROM mutation_log ORDER BY id ASC')
27
+ const results = stmt.select<MutationLogRow>(undefined)
28
+
29
+ performance.mark('livestore:hydrate-from-mutationlog:start')
30
+
31
+ for (const row of results) {
32
+ const mutationDef = schema.mutations.get(row.mutation) ?? shouldNeverHappen(`Unknown mutation ${row.mutation}`)
33
+
34
+ if (Schema.hash(mutationDef.schema) !== row.schema_hash) {
35
+ throw new Error(`Schema hash mismatch for mutation ${row.mutation}`)
36
+ }
37
+
38
+ const argsDecoded = Schema.decodeUnknownSync(Schema.parseJson(mutationDef.schema))(row.args_json)
39
+ const mutationEventDecoded = {
40
+ id: row.id,
41
+ mutation: row.mutation,
42
+ args: argsDecoded,
43
+ }
44
+ // const argsEncoded = JSON.parse(row.args_json)
45
+ // const mutationSqlRes =
46
+ // typeof mutation.sql === 'string'
47
+ // ? mutation.sql
48
+ // : mutation.sql(Schema.decodeUnknownSync(mutation.schema)(argsEncoded))
49
+ // const mutationSql = typeof mutationSqlRes === 'string' ? mutationSqlRes : mutationSqlRes.sql
50
+ // const bindValues = typeof mutationSqlRes === 'string' ? argsEncoded : mutationSqlRes.bindValues
51
+
52
+ const execArgsArr = getExecArgsFromMutation({ mutationDef, mutationEventDecoded })
53
+
54
+ for (const { statementSql, bindValues } of execArgsArr) {
55
+ try {
56
+ db.execute(statementSql, bindValues)
57
+ // console.log(`Re-executed mutation ${mutationSql}`, bindValues)
58
+ } catch (e) {
59
+ console.error(`Error executing migration for mutation ${statementSql}`, bindValues, e)
60
+ debugger
61
+ throw e
62
+ }
63
+ }
64
+ }
65
+ } catch (e) {
66
+ console.error('Error while rehydrating database from mutation log', e)
67
+ debugger
68
+ throw e
69
+ } finally {
70
+ performance.mark('livestore:hydrate-from-mutationlog:end')
71
+ performance.measure(
72
+ 'livestore:hydrate-from-mutationlog',
73
+ 'livestore:hydrate-from-mutationlog:start',
74
+ 'livestore:hydrate-from-mutationlog:end',
75
+ )
76
+ }
77
+ }
@@ -1,6 +1,6 @@
1
1
  import { isReadonlyArray } from '@livestore/utils'
2
2
  import type { ReadonlyArray } from '@livestore/utils/effect'
3
- import type { SqliteDsl } from 'effect-db-schema'
3
+ import { SqliteAst, type SqliteDsl } from 'effect-db-schema'
4
4
 
5
5
  import {
6
6
  type MutationDef,
@@ -80,6 +80,12 @@ export const makeSchema = <TInputSchema extends InputSchema>(
80
80
  } satisfies LiveStoreSchema
81
81
  }
82
82
 
83
+ export const makeSchemaHash = (schema: LiveStoreSchema) =>
84
+ SqliteAst.hash({
85
+ _tag: 'dbSchema',
86
+ tables: [...schema.tables.values()].map((_) => _.sqliteDef.ast),
87
+ })
88
+
83
89
  /**
84
90
  * In case of ...
85
91
  * - array: we use the table name of each array item (= table definition) as the object key
@@ -17,19 +17,24 @@ export type InternalMutationSchema<TRecord extends MutationDefRecord = MutationD
17
17
  schemaHashMap: Map<keyof TRecord, number>
18
18
  }
19
19
 
20
+ export type MutationDefSqlResult<TTo> =
21
+ | SingleOrReadonlyArray<string>
22
+ | ((args: TTo) => SingleOrReadonlyArray<
23
+ | string
24
+ | {
25
+ sql: string
26
+ /** Note args need to be manually encoded to `BindValues` when returning this argument */
27
+ bindValues: BindValues
28
+ writeTables?: ReadonlySet<string>
29
+ }
30
+ >)
31
+
32
+ export type SingleOrReadonlyArray<T> = T | ReadonlyArray<T>
33
+
20
34
  export type MutationDef<TName extends string, TFrom, TTo> = {
21
35
  name: TName
22
36
  schema: Schema.Schema<TTo, TFrom>
23
- sql:
24
- | string
25
- | ((args: TTo) =>
26
- | string
27
- | {
28
- sql: string
29
- /** Note args need to be manually encoded to `BindValues` when returning this argument */
30
- bindValues: BindValues
31
- writeTables?: ReadonlySet<string>
32
- })
37
+ sql: MutationDefSqlResult<TTo>
33
38
 
34
39
  /** Helper function to construct mutation event */
35
40
  (args: TTo): { mutation: TName; args: TTo; id: string }
@@ -43,7 +48,7 @@ export namespace MutationDef {
43
48
  export const defineMutation = <TName extends string, TFrom, TTo>(
44
49
  name: TName,
45
50
  schema: Schema.Schema<TTo, TFrom>,
46
- sql: string | ((args: TTo) => string | { sql: string; bindValues: BindValues; writeTables?: ReadonlySet<string> }),
51
+ sql: MutationDefSqlResult<TTo>,
47
52
  ): MutationDef<TName, TFrom, TTo> => {
48
53
  const makeEvent = (args: TTo) => ({ mutation: name, args, id: cuid() })
49
54
 
@@ -198,15 +198,17 @@ export const getDefaultValuesDecoded = <TTableDef extends TableDef>(
198
198
  )
199
199
 
200
200
  type WithId<TColumns extends SqliteDsl.Columns, TOptions extends TableOptions> = TColumns &
201
- (TOptions['disableAutomaticIdColumn'] extends true
201
+ ('id' extends keyof TColumns
202
202
  ? {}
203
- : TOptions['isSingleton'] extends true
204
- ? {
205
- id: SqliteDsl.ColumnDefinition<'singleton', 'singleton'>
206
- }
207
- : {
208
- id: SqliteDsl.ColumnDefinition<string, string>
209
- })
203
+ : TOptions['disableAutomaticIdColumn'] extends true
204
+ ? {}
205
+ : TOptions['isSingleton'] extends true
206
+ ? {
207
+ id: SqliteDsl.ColumnDefinition<'singleton', 'singleton'>
208
+ }
209
+ : {
210
+ id: SqliteDsl.ColumnDefinition<string, string>
211
+ })
210
212
 
211
213
  type WithDefaults<TOptionsInput extends TableOptionsInput> = {
212
214
  isSingleton: TOptionsInput['isSingleton'] extends true ? true : false