@livestore/common 0.4.0-dev.13 → 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 (77) 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/devtools/devtools-sessioninfo.d.ts +12 -0
  11. package/dist/devtools/devtools-sessioninfo.d.ts.map +1 -1
  12. package/dist/devtools/devtools-sessioninfo.js +6 -0
  13. package/dist/devtools/devtools-sessioninfo.js.map +1 -1
  14. package/dist/devtools/mod.d.ts +13 -2
  15. package/dist/devtools/mod.d.ts.map +1 -1
  16. package/dist/devtools/mod.js +10 -3
  17. package/dist/devtools/mod.js.map +1 -1
  18. package/dist/leader-thread/LeaderSyncProcessor.js +2 -2
  19. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  20. package/dist/leader-thread/make-leader-thread-layer.d.ts +5 -4
  21. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  22. package/dist/leader-thread/make-leader-thread-layer.js +4 -3
  23. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  24. package/dist/make-client-session.d.ts +3 -1
  25. package/dist/make-client-session.d.ts.map +1 -1
  26. package/dist/make-client-session.js +5 -2
  27. package/dist/make-client-session.js.map +1 -1
  28. package/dist/schema/EventSequenceNumber.d.ts +10 -4
  29. package/dist/schema/EventSequenceNumber.d.ts.map +1 -1
  30. package/dist/schema/EventSequenceNumber.js +10 -6
  31. package/dist/schema/EventSequenceNumber.js.map +1 -1
  32. package/dist/schema/EventSequenceNumber.test.js +7 -7
  33. package/dist/schema/LiveStoreEvent.d.ts +4 -4
  34. package/dist/schema/LiveStoreEvent.js +4 -4
  35. package/dist/schema/state/sqlite/column-spec.d.ts.map +1 -1
  36. package/dist/schema/state/sqlite/column-spec.js +22 -8
  37. package/dist/schema/state/sqlite/column-spec.js.map +1 -1
  38. package/dist/schema/state/sqlite/column-spec.test.js +13 -14
  39. package/dist/schema/state/sqlite/column-spec.test.js.map +1 -1
  40. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts +2 -0
  41. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts.map +1 -0
  42. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js +73 -0
  43. package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js.map +1 -0
  44. package/dist/schema-management/migrations.js +6 -4
  45. package/dist/schema-management/migrations.js.map +1 -1
  46. package/dist/sync/sync-backend.d.ts +3 -3
  47. package/dist/sync/sync-backend.d.ts.map +1 -1
  48. package/dist/sync/sync.d.ts +3 -2
  49. package/dist/sync/sync.d.ts.map +1 -1
  50. package/dist/sync/syncstate.d.ts +2 -2
  51. package/dist/sync/syncstate.d.ts.map +1 -1
  52. package/dist/sync/syncstate.js +9 -4
  53. package/dist/sync/syncstate.js.map +1 -1
  54. package/dist/sync/syncstate.test.js +36 -0
  55. package/dist/sync/syncstate.test.js.map +1 -1
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +4 -4
  59. package/src/adapter-types.ts +4 -2
  60. package/src/debug-info.ts +37 -6
  61. package/src/devtools/devtools-sessioninfo.ts +6 -0
  62. package/src/devtools/mod.ts +11 -2
  63. package/src/leader-thread/LeaderSyncProcessor.ts +2 -2
  64. package/src/leader-thread/make-leader-thread-layer.ts +10 -4
  65. package/src/make-client-session.ts +7 -1
  66. package/src/schema/EventSequenceNumber.test.ts +7 -7
  67. package/src/schema/EventSequenceNumber.ts +13 -7
  68. package/src/schema/LiveStoreEvent.ts +4 -4
  69. package/src/schema/state/sqlite/column-spec.test.ts +13 -16
  70. package/src/schema/state/sqlite/column-spec.ts +29 -9
  71. package/src/schema-management/__tests__/migrations-autoincrement-quoting.test.ts +86 -0
  72. package/src/schema-management/migrations.ts +6 -4
  73. package/src/sync/sync-backend.ts +4 -4
  74. package/src/sync/sync.ts +3 -2
  75. package/src/sync/syncstate.test.ts +42 -0
  76. package/src/sync/syncstate.ts +11 -4
  77. package/src/version.ts +1 -1
@@ -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
  /**
@@ -441,6 +441,48 @@ describe('syncstate', () => {
441
441
  expectEventArraysEqual(result.newEvents, [e1_1, e1_2, e1_3])
442
442
  expectEventArraysEqual(result.confirmedEvents, [])
443
443
  })
444
+
445
+ // Leaders can choose to ignore client-only events while still returning them for broadcast.
446
+ // Ensure pending/local head only reflects events that must be pushed upstream.
447
+ it('keeps pending empty when pushing only client-only events that are being ignored', () => {
448
+ const syncState = new SyncState.SyncState({
449
+ pending: [],
450
+ upstreamHead: EventSequenceNumber.ROOT,
451
+ localHead: EventSequenceNumber.ROOT,
452
+ })
453
+
454
+ const result = merge({
455
+ syncState,
456
+ payload: SyncState.PayloadLocalPush.make({ newEvents: [e0_1] }),
457
+ ignoreClientEvents: true,
458
+ })
459
+
460
+ expectAdvance(result)
461
+ expectEventArraysEqual(result.newSyncState.pending, [])
462
+ expect(result.newSyncState.upstreamHead).toMatchObject(EventSequenceNumber.ROOT)
463
+ expect(result.newSyncState.localHead).toMatchObject(EventSequenceNumber.ROOT)
464
+ expectEventArraysEqual(result.newEvents, [e0_1])
465
+ })
466
+
467
+ it('appends only upstream-bound events to pending when ignoring client-only pushes', () => {
468
+ const syncState = new SyncState.SyncState({
469
+ pending: [],
470
+ upstreamHead: EventSequenceNumber.ROOT,
471
+ localHead: EventSequenceNumber.ROOT,
472
+ })
473
+
474
+ const result = merge({
475
+ syncState,
476
+ payload: SyncState.PayloadLocalPush.make({ newEvents: [e0_1, e1_0] }),
477
+ ignoreClientEvents: true,
478
+ })
479
+
480
+ expectAdvance(result)
481
+ expectEventArraysEqual(result.newSyncState.pending, [e1_0])
482
+ expect(result.newSyncState.upstreamHead).toMatchObject(EventSequenceNumber.ROOT)
483
+ expect(result.newSyncState.localHead).toMatchObject(e1_0.seqNum)
484
+ expectEventArraysEqual(result.newEvents, [e0_1, e1_0])
485
+ })
444
486
  })
445
487
 
446
488
  describe('reject', () => {
@@ -15,7 +15,7 @@ import * as LiveStoreEvent from '../schema/LiveStoreEvent.ts'
15
15
  * +------------------------+
16
16
  * ▼ ▼
17
17
  * Upstream Head Local Head
18
- * (1,0) (1,1), (1,2), (2,0)
18
+ * e1 e1.1, e1.2, e2
19
19
  * ```
20
20
  *
21
21
  * **Pending Events**: Events awaiting acknowledgment from the upstream.
@@ -31,7 +31,7 @@ import * as LiveStoreEvent from '../schema/LiveStoreEvent.ts'
31
31
  * Invariants:
32
32
  * 1. **Chain Continuity**: Each event must reference its immediate parent.
33
33
  * 2. **Head Ordering**: Upstream Head ≤ Local Head.
34
- * 3. **Event number sequence**: Must follow the pattern (1,0)(1,1)(1,2)(2,0).
34
+ * 3. **Event number sequence**: Must follow the pattern e1e1.1→e1.2→e2.
35
35
  *
36
36
  * A few further notes to help form an intuition:
37
37
  * - The goal is to keep the pending events as small as possible (i.e. to have synced with the next upstream node)
@@ -407,13 +407,20 @@ export const merge = ({
407
407
  }),
408
408
  )
409
409
  } else {
410
+ const nonClientEvents = ignoreClientEvents
411
+ ? payload.newEvents.filter((event) => !isClientEvent(event))
412
+ : payload.newEvents
413
+ const newPending = [...syncState.pending, ...nonClientEvents]
414
+ const newLocalHead =
415
+ newPending.at(-1)?.seqNum ?? EventSequenceNumber.max(syncState.localHead, syncState.upstreamHead)
416
+
410
417
  return validateMergeResult(
411
418
  MergeResultAdvance.make({
412
419
  _tag: 'advance',
413
420
  newSyncState: new SyncState({
414
- pending: [...syncState.pending, ...payload.newEvents],
421
+ pending: newPending,
415
422
  upstreamHead: syncState.upstreamHead,
416
- localHead: payload.newEvents.at(-1)!.seqNum,
423
+ localHead: newLocalHead,
417
424
  }),
418
425
  newEvents: payload.newEvents,
419
426
  confirmedEvents: [],
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.13' 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.