@livestore/common 0.4.0-dev.14 → 0.4.0-dev.15

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 (41) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +4 -2
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/debug-info.d.ts.map +1 -1
  5. package/dist/debug-info.js +33 -6
  6. package/dist/debug-info.js.map +1 -1
  7. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  8. package/dist/devtools/devtools-messages-common.d.ts +6 -6
  9. package/dist/devtools/devtools-messages-leader.d.ts +24 -24
  10. package/dist/leader-thread/make-leader-thread-layer.d.ts +5 -4
  11. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  12. package/dist/leader-thread/make-leader-thread-layer.js +4 -3
  13. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  14. package/dist/schema/state/sqlite/column-spec.d.ts.map +1 -1
  15. package/dist/schema/state/sqlite/column-spec.js +22 -8
  16. package/dist/schema/state/sqlite/column-spec.js.map +1 -1
  17. package/dist/schema/state/sqlite/column-spec.test.js +13 -14
  18. package/dist/schema/state/sqlite/column-spec.test.js.map +1 -1
  19. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts +2 -0
  20. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts.map +1 -0
  21. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js +73 -0
  22. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js.map +1 -0
  23. package/dist/schema-management/migrations.js +6 -4
  24. package/dist/schema-management/migrations.js.map +1 -1
  25. package/dist/sync/sync-backend.d.ts +3 -3
  26. package/dist/sync/sync-backend.d.ts.map +1 -1
  27. package/dist/sync/sync.d.ts +3 -2
  28. package/dist/sync/sync.d.ts.map +1 -1
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/package.json +4 -4
  32. package/src/adapter-types.ts +4 -2
  33. package/src/debug-info.ts +37 -6
  34. package/src/leader-thread/make-leader-thread-layer.ts +10 -4
  35. package/src/schema/state/sqlite/column-spec.test.ts +13 -16
  36. package/src/schema/state/sqlite/column-spec.ts +29 -9
  37. package/src/schema-management/__tests__/migrations-autoincrement-quoting.test.ts +86 -0
  38. package/src/schema-management/migrations.ts +6 -4
  39. package/src/sync/sync-backend.ts +4 -4
  40. package/src/sync/sync.ts +3 -2
  41. package/src/version.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  import { omitUndefineds, shouldNeverHappen } from '@livestore/utils'
2
- import type { HttpClient, Schema, Scope } from '@livestore/utils/effect'
2
+ import type { HttpClient, Scope } from '@livestore/utils/effect'
3
3
  import {
4
4
  Deferred,
5
5
  Effect,
@@ -7,6 +7,7 @@ import {
7
7
  Layer,
8
8
  PlatformError,
9
9
  Queue,
10
+ Schema,
10
11
  Stream,
11
12
  Subscribable,
12
13
  SubscriptionRef,
@@ -44,7 +45,8 @@ import { LeaderThreadCtx } from './types.ts'
44
45
 
45
46
  export interface MakeLeaderThreadLayerParams {
46
47
  storeId: string
47
- syncPayload: Schema.JsonValue | undefined
48
+ syncPayloadSchema: Schema.Schema<any> | undefined
49
+ syncPayloadEncoded: Schema.JsonValue | undefined
48
50
  clientId: string
49
51
  schema: LiveStoreSchema
50
52
  makeSqliteDb: MakeSqliteDb
@@ -70,7 +72,8 @@ export const makeLeaderThreadLayer = ({
70
72
  schema,
71
73
  storeId,
72
74
  clientId,
73
- syncPayload,
75
+ syncPayloadSchema = Schema.JsonValue,
76
+ syncPayloadEncoded,
74
77
  makeSqliteDb,
75
78
  syncOptions,
76
79
  dbState,
@@ -81,6 +84,9 @@ export const makeLeaderThreadLayer = ({
81
84
  testing,
82
85
  }: MakeLeaderThreadLayerParams): Layer.Layer<LeaderThreadCtx, UnexpectedError, Scope.Scope | HttpClient.HttpClient> =>
83
86
  Effect.gen(function* () {
87
+ const syncPayloadDecoded =
88
+ syncPayloadEncoded === undefined ? undefined : yield* Schema.decodeUnknown(syncPayloadSchema)(syncPayloadEncoded)
89
+
84
90
  const bootStatusQueue = yield* Queue.unbounded<BootStatus>().pipe(Effect.acquireRelease(Queue.shutdown))
85
91
 
86
92
  const dbEventlogMissing = !hasEventlogTables(dbEventlog)
@@ -93,7 +99,7 @@ export const makeLeaderThreadLayer = ({
93
99
  const syncBackend =
94
100
  syncOptions?.backend === undefined
95
101
  ? undefined
96
- : yield* syncOptions.backend({ storeId, clientId, payload: syncPayload }).pipe(
102
+ : yield* syncOptions.backend({ storeId, clientId, payload: syncPayloadDecoded }).pipe(
97
103
  Effect.provide(
98
104
  Layer.succeed(
99
105
  KeyValueStore.KeyValueStore,
@@ -56,9 +56,9 @@ describe('makeColumnSpec', () => {
56
56
  )
57
57
 
58
58
  const result = makeColumnSpec(table)
59
- expect(result).toMatchInlineSnapshot(`"'order' integer not null , 'group' text "`)
60
- expect(result).toContain("'order'")
61
- expect(result).toContain("'group'")
59
+ expect(result).toMatchInlineSnapshot(`""order" integer not null , "group" text "`)
60
+ expect(result).toContain('"order"')
61
+ expect(result).toContain('"group"')
62
62
  })
63
63
 
64
64
  it('should handle basic columns with primary keys', () => {
@@ -69,8 +69,7 @@ describe('makeColumnSpec', () => {
69
69
  )
70
70
 
71
71
  const result = makeColumnSpec(table)
72
- expect(result).toMatchInlineSnapshot(`"'id' text not null , 'name' text , PRIMARY KEY ('id')"`)
73
- expect(result).toContain("PRIMARY KEY ('id')")
72
+ expect(result).toMatchInlineSnapshot(`""id" text primary key , "name" text "`)
74
73
  })
75
74
 
76
75
  it('should handle multi-column primary keys', () => {
@@ -85,9 +84,9 @@ describe('makeColumnSpec', () => {
85
84
 
86
85
  const result = makeColumnSpec(table)
87
86
  expect(result).toMatchInlineSnapshot(
88
- `"'tenant_id' text not null , 'user_id' text not null , PRIMARY KEY ('tenant_id', 'user_id')"`,
87
+ `""tenant_id" text not null , "user_id" text not null , PRIMARY KEY ("tenant_id", "user_id")"`,
89
88
  )
90
- expect(result).toContain("PRIMARY KEY ('tenant_id', 'user_id')")
89
+ expect(result).toContain('PRIMARY KEY ("tenant_id", "user_id")')
91
90
  })
92
91
 
93
92
  it('should handle auto-increment columns', () => {
@@ -101,9 +100,9 @@ describe('makeColumnSpec', () => {
101
100
  )
102
101
 
103
102
  const result = makeColumnSpec(table)
104
- expect(result).toMatchInlineSnapshot(`"'id' integer not null autoincrement , 'title' text , PRIMARY KEY ('id')"`)
103
+ expect(result).toMatchInlineSnapshot(`""id" integer primary key autoincrement , "title" text "`)
105
104
  expect(result).toContain('autoincrement')
106
- expect(result).toContain("PRIMARY KEY ('id')")
105
+ expect(result).not.toContain("PRIMARY KEY ('id')")
107
106
  })
108
107
 
109
108
  it('should handle columns with default values', () => {
@@ -121,7 +120,7 @@ describe('makeColumnSpec', () => {
121
120
 
122
121
  const result = makeColumnSpec(table)
123
122
  expect(result).toMatchInlineSnapshot(
124
- `"'id' integer not null , 'name' text not null , 'price' real default 0, 'active' integer default true, 'description' text default 'No description', PRIMARY KEY ('id')"`,
123
+ `""id" integer primary key , "name" text not null , "price" real default 0, "active" integer default true, "description" text default 'No description'"`,
125
124
  )
126
125
  expect(result).toContain('default 0')
127
126
  expect(result).toContain('default true')
@@ -141,7 +140,7 @@ describe('makeColumnSpec', () => {
141
140
 
142
141
  const result = makeColumnSpec(table)
143
142
  expect(result).toMatchInlineSnapshot(
144
- `"'id' integer not null , 'created_at' text default CURRENT_TIMESTAMP, 'random_value' real default RANDOM(), PRIMARY KEY ('id')"`,
143
+ `""id" integer primary key , "created_at" text default CURRENT_TIMESTAMP, "random_value" real default RANDOM()"`,
145
144
  )
146
145
  expect(result).toContain('default CURRENT_TIMESTAMP')
147
146
  expect(result).toContain('default RANDOM()')
@@ -158,9 +157,7 @@ describe('makeColumnSpec', () => {
158
157
  )
159
158
 
160
159
  const result = makeColumnSpec(table)
161
- expect(result).toMatchInlineSnapshot(
162
- `"'id' integer not null , 'optional_text' text default null, PRIMARY KEY ('id')"`,
163
- )
160
+ expect(result).toMatchInlineSnapshot(`""id" integer primary key , "optional_text" text default null"`)
164
161
  expect(result).toContain('default null')
165
162
  })
166
163
 
@@ -190,7 +187,7 @@ describe('makeColumnSpec', () => {
190
187
 
191
188
  const result = makeColumnSpec(table)
192
189
  expect(result).toMatchInlineSnapshot(
193
- `"'id' integer not null autoincrement , 'name' text not null default 'Unnamed', 'created_at' text not null default CURRENT_TIMESTAMP, 'status' text default 'pending', PRIMARY KEY ('id')"`,
190
+ `""id" integer primary key autoincrement , "name" text not null default 'Unnamed', "created_at" text not null default CURRENT_TIMESTAMP, "status" text default 'pending'"`,
194
191
  )
195
192
  })
196
193
 
@@ -213,7 +210,7 @@ describe('makeColumnSpec', () => {
213
210
  const result = makeColumnSpec(table)
214
211
  // The makeColumnSpec function only generates column specifications, not indexes
215
212
  expect(result).toMatchInlineSnapshot(
216
- `"'id' integer not null autoincrement , 'email' text not null , 'username' text not null , 'created_at' text default CURRENT_TIMESTAMP, PRIMARY KEY ('id')"`,
213
+ `""id" integer primary key autoincrement , "email" text not null , "username" text not null , "created_at" text default CURRENT_TIMESTAMP"`,
217
214
  )
218
215
  // Verify the table has the indexes (even though they're not in the column spec)
219
216
  expect(table.indexes).toHaveLength(3)
@@ -10,21 +10,40 @@ import { type SqliteAst, SqliteDsl } from './db-schema/mod.ts'
10
10
  * ```
11
11
  */
12
12
  export const makeColumnSpec = (tableAst: SqliteAst.Table) => {
13
- const primaryKeys = tableAst.columns.filter((_) => _.primaryKey).map((_) => `'${_.name}'`)
14
- const columnDefStrs = tableAst.columns.map(toSqliteColumnSpec)
15
-
16
- if (primaryKeys.length > 0) {
17
- columnDefStrs.push(`PRIMARY KEY (${primaryKeys.join(', ')})`)
13
+ const pkColumns = tableAst.columns.filter((_) => _.primaryKey)
14
+ const hasSinglePk = pkColumns.length === 1
15
+ const pkColumn = hasSinglePk ? pkColumns[0] : undefined
16
+
17
+ // Build column definitions, handling the special SQLite rule that AUTOINCREMENT
18
+ // is only valid on a single column declared as INTEGER PRIMARY KEY (column-level).
19
+ const columnDefStrs = tableAst.columns.map((column) =>
20
+ toSqliteColumnSpec(column, {
21
+ inlinePrimaryKey: hasSinglePk && column === pkColumn && column.primaryKey === true,
22
+ }),
23
+ )
24
+
25
+ // For composite primary keys, add a table-level PRIMARY KEY clause.
26
+ if (pkColumns.length > 1) {
27
+ const quotedPkCols = pkColumns.map((_) => `"${_.name}"`)
28
+ columnDefStrs.push(`PRIMARY KEY (${quotedPkCols.join(', ')})`)
18
29
  }
19
30
 
20
31
  return columnDefStrs.join(', ')
21
32
  }
22
33
 
23
34
  /** NOTE primary keys are applied on a table level not on a column level to account for multi-column primary keys */
24
- const toSqliteColumnSpec = (column: SqliteAst.Column) => {
35
+ const toSqliteColumnSpec = (column: SqliteAst.Column, opts: { inlinePrimaryKey: boolean }) => {
25
36
  const columnTypeStr = column.type._tag
26
- const nullableStr = column.nullable === false ? 'not null' : ''
27
- const autoIncrementStr = column.autoIncrement ? 'autoincrement' : ''
37
+ // When PRIMARY KEY is declared inline, NOT NULL is implied and should not be emitted,
38
+ // and AUTOINCREMENT must immediately follow PRIMARY KEY within the same constraint.
39
+ const nullableStr = opts.inlinePrimaryKey ? '' : column.nullable === false ? 'not null' : ''
40
+
41
+ // Only include AUTOINCREMENT when it's valid: single-column INTEGER PRIMARY KEY
42
+ const includeAutoIncrement = opts.inlinePrimaryKey && column.type._tag === 'integer' && column.autoIncrement === true
43
+
44
+ const pkStr = opts.inlinePrimaryKey ? 'primary key' : ''
45
+ const autoIncrementStr = includeAutoIncrement ? 'autoincrement' : ''
46
+
28
47
  const defaultValueStr = (() => {
29
48
  if (column.default._tag === 'None') return ''
30
49
 
@@ -38,5 +57,6 @@ const toSqliteColumnSpec = (column: SqliteAst.Column) => {
38
57
  return `default ${encodedDefaultValue}`
39
58
  })()
40
59
 
41
- return `'${column.name}' ${columnTypeStr} ${nullableStr} ${autoIncrementStr} ${defaultValueStr}`
60
+ // Ensure order: PRIMARY KEY [AUTOINCREMENT] [NOT NULL] ...
61
+ return `"${column.name}" ${columnTypeStr} ${pkStr} ${autoIncrementStr} ${nullableStr} ${defaultValueStr}`
42
62
  }
@@ -0,0 +1,86 @@
1
+ import { Effect, Option, Schema } from '@livestore/utils/effect'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { SqliteAst } from '../../schema/state/sqlite/db-schema/mod.ts'
4
+ import type { PreparedStatement, SqliteDb } from '../../sqlite-types.ts'
5
+ import type { PreparedBindValues } from '../../util.ts'
6
+ import { migrateTable } from '../migrations.ts'
7
+
8
+ const makeStubDb = () => {
9
+ const executed: string[] = []
10
+
11
+ const db: SqliteDb = {
12
+ _tag: 'SqliteDb',
13
+ metadata: { dbPointer: 0, persistenceInfo: { fileName: ':memory:' } } as any,
14
+ debug: { head: 0 as any },
15
+ prepare: (queryStr: string): PreparedStatement => ({
16
+ sql: queryStr,
17
+ execute: (_bind: PreparedBindValues | undefined) => {
18
+ executed.push(queryStr)
19
+ },
20
+ select: <T>(_bind: PreparedBindValues | undefined) => [] as unknown as ReadonlyArray<T>,
21
+ finalize: () => {},
22
+ }),
23
+ execute: () => {},
24
+ select: () => [],
25
+ export: () => new Uint8Array(),
26
+ import: () => {},
27
+ close: () => {},
28
+ destroy: () => {},
29
+ session: () => ({ changeset: () => undefined, finish: () => {} }),
30
+ makeChangeset: () => ({ invert: () => ({ invert: () => ({}) as any, apply: () => {} }) as any, apply: () => {} }),
31
+ }
32
+
33
+ return { db, executed }
34
+ }
35
+
36
+ describe('migrateTable - quoting and autoincrement', () => {
37
+ it('creates valid CREATE TABLE with inline INTEGER PRIMARY KEY AUTOINCREMENT and double-quoted identifiers', () => {
38
+ const { db, executed } = makeStubDb()
39
+
40
+ const table = SqliteAst.table(
41
+ 'todos',
42
+ [
43
+ SqliteAst.column({
44
+ name: 'id',
45
+ type: { _tag: 'integer' },
46
+ nullable: false,
47
+ primaryKey: true,
48
+ autoIncrement: true,
49
+ default: Option.none(),
50
+ schema: Schema.Number,
51
+ }),
52
+ SqliteAst.column({
53
+ name: 'text',
54
+ type: { _tag: 'text' },
55
+ nullable: false,
56
+ primaryKey: false,
57
+ autoIncrement: false,
58
+ default: Option.some(''),
59
+ schema: Schema.String,
60
+ }),
61
+ SqliteAst.column({
62
+ name: 'completed',
63
+ type: { _tag: 'integer' },
64
+ nullable: false,
65
+ primaryKey: false,
66
+ autoIncrement: false,
67
+ default: Option.some(0),
68
+ schema: Schema.Number,
69
+ }),
70
+ ],
71
+ [],
72
+ )
73
+
74
+ migrateTable({ db, tableAst: table, behaviour: 'create-if-not-exists', skipMetaTable: true }).pipe(Effect.runSync)
75
+
76
+ const createStmt = executed.find((s) => /create table if not exists/i.test(s))
77
+ expect(createStmt).toBeDefined()
78
+
79
+ // Identifiers must be double-quoted, not single-quoted
80
+ expect(createStmt!).toContain('create table if not exists "todos"')
81
+ expect(createStmt!).toContain('"id" integer primary key autoincrement')
82
+ expect(createStmt!).toContain(" default ''")
83
+ expect(createStmt!).not.toContain("PRIMARY KEY ('id')")
84
+ expect(createStmt!).not.toMatch(/'todos'|'id'|'text'/)
85
+ })
86
+ })
@@ -168,10 +168,10 @@ export const migrateTable = ({
168
168
 
169
169
  if (behaviour === 'drop-and-recreate') {
170
170
  // TODO need to possibly handle cascading deletes due to foreign keys
171
- dbExecute(db, sql`drop table if exists '${tableName}'`)
172
- dbExecute(db, sql`create table if not exists '${tableName}' (${columnSpec}) strict`)
171
+ dbExecute(db, sql`drop table if exists "${tableName}"`)
172
+ dbExecute(db, sql`create table if not exists "${tableName}" (${columnSpec}) strict`)
173
173
  } else if (behaviour === 'create-if-not-exists') {
174
- dbExecute(db, sql`create table if not exists '${tableName}' (${columnSpec}) strict`)
174
+ dbExecute(db, sql`create table if not exists "${tableName}" (${columnSpec}) strict`)
175
175
  }
176
176
 
177
177
  for (const index of tableAst.indexes) {
@@ -201,5 +201,7 @@ export const migrateTable = ({
201
201
 
202
202
  const createIndexFromDefinition = (tableName: string, index: SqliteAst.Index) => {
203
203
  const uniqueStr = index.unique ? 'UNIQUE' : ''
204
- return sql`create ${uniqueStr} index if not exists '${index.name}' on '${tableName}' (${index.columns.map((col) => `'${col}'`).join(', ')})`
204
+ return sql`create ${uniqueStr} index if not exists "${index.name}" on "${tableName}" (${index.columns
205
+ .map((col) => `"${col}"`)
206
+ .join(', ')})`
205
207
  }
@@ -19,15 +19,15 @@ export * from './sync-backend-kv.ts'
19
19
  /**
20
20
  * Those arguments can be used to implement multi-tenancy etc and are passed in from the store.
21
21
  */
22
- export type MakeBackendArgs = {
22
+ export type MakeBackendArgs<TPayload = Schema.JsonValue> = {
23
23
  storeId: string
24
24
  clientId: string
25
- payload: Schema.JsonValue | undefined
25
+ payload: TPayload | undefined
26
26
  }
27
27
 
28
28
  // TODO rename to `SyncProviderClientConstructor`
29
- export type SyncBackendConstructor<TSyncMetadata = Schema.JsonValue> = (
30
- args: MakeBackendArgs,
29
+ export type SyncBackendConstructor<TSyncMetadata = Schema.JsonValue, TPayload = Schema.JsonValue> = (
30
+ args: MakeBackendArgs<TPayload>,
31
31
  ) => Effect.Effect<
32
32
  SyncBackend<TSyncMetadata>,
33
33
  UnexpectedError,
package/src/sync/sync.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  export * from './errors.ts'
2
2
  export * as SyncBackend from './sync-backend.ts'
3
3
 
4
+ import type { Schema } from '@livestore/utils/effect'
4
5
  import type { InitialSyncOptions } from '../leader-thread/types.ts'
5
6
  import type { SyncBackendConstructor } from './sync-backend.ts'
6
7
 
7
- export type SyncOptions = {
8
- backend?: SyncBackendConstructor<any>
8
+ export type SyncOptions<TPayload = Schema.JsonValue> = {
9
+ backend?: SyncBackendConstructor<any, TPayload>
9
10
  /** @default { _tag: 'Skip' } */
10
11
  initialSyncOptions?: InitialSyncOptions
11
12
  /**
package/src/version.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // import packageJson from '../package.json' with { type: 'json' }
3
3
  // export const liveStoreVersion = packageJson.version
4
4
 
5
- export const liveStoreVersion = '0.4.0-dev.14' as const
5
+ export const liveStoreVersion = '0.4.0-dev.15' as const
6
6
 
7
7
  /**
8
8
  * CRITICAL: Increment this version whenever you modify client-side EVENTLOG table schemas.