@livestore/common 0.4.0-dev.1 → 0.4.0-dev.10

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 (253) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +7 -2
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
  4. package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
  5. package/dist/adapter-types.d.ts +9 -3
  6. package/dist/adapter-types.d.ts.map +1 -1
  7. package/dist/adapter-types.js.map +1 -1
  8. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  9. package/dist/devtools/devtools-messages-common.d.ts +7 -14
  10. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  11. package/dist/devtools/devtools-messages-common.js +1 -6
  12. package/dist/devtools/devtools-messages-common.js.map +1 -1
  13. package/dist/devtools/devtools-messages-leader.d.ts +27 -25
  14. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  15. package/dist/errors.d.ts +47 -5
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +22 -3
  18. package/dist/errors.js.map +1 -1
  19. package/dist/leader-thread/LeaderSyncProcessor.d.ts +7 -3
  20. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  21. package/dist/leader-thread/LeaderSyncProcessor.js +122 -49
  22. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  23. package/dist/leader-thread/eventlog.d.ts +4 -10
  24. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  25. package/dist/leader-thread/eventlog.js +4 -6
  26. package/dist/leader-thread/eventlog.js.map +1 -1
  27. package/dist/leader-thread/leader-worker-devtools.d.ts +1 -1
  28. package/dist/leader-thread/leader-worker-devtools.js +6 -2
  29. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  30. package/dist/leader-thread/make-leader-thread-layer.d.ts +1 -2
  31. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  32. package/dist/leader-thread/make-leader-thread-layer.js +68 -19
  33. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  34. package/dist/leader-thread/make-leader-thread-layer.test.d.ts +2 -0
  35. package/dist/leader-thread/make-leader-thread-layer.test.d.ts.map +1 -0
  36. package/dist/leader-thread/make-leader-thread-layer.test.js +32 -0
  37. package/dist/leader-thread/make-leader-thread-layer.test.js.map +1 -0
  38. package/dist/leader-thread/materialize-event.d.ts +2 -2
  39. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  40. package/dist/leader-thread/materialize-event.js +23 -9
  41. package/dist/leader-thread/materialize-event.js.map +1 -1
  42. package/dist/leader-thread/recreate-db.d.ts +2 -3
  43. package/dist/leader-thread/recreate-db.d.ts.map +1 -1
  44. package/dist/leader-thread/recreate-db.js +1 -1
  45. package/dist/leader-thread/recreate-db.js.map +1 -1
  46. package/dist/leader-thread/shutdown-channel.d.ts +2 -2
  47. package/dist/leader-thread/shutdown-channel.d.ts.map +1 -1
  48. package/dist/leader-thread/shutdown-channel.js +2 -2
  49. package/dist/leader-thread/shutdown-channel.js.map +1 -1
  50. package/dist/leader-thread/types.d.ts +7 -5
  51. package/dist/leader-thread/types.d.ts.map +1 -1
  52. package/dist/leader-thread/types.js.map +1 -1
  53. package/dist/materializer-helper.d.ts +1 -1
  54. package/dist/materializer-helper.d.ts.map +1 -1
  55. package/dist/materializer-helper.js +20 -4
  56. package/dist/materializer-helper.js.map +1 -1
  57. package/dist/rematerialize-from-eventlog.d.ts +1 -1
  58. package/dist/rematerialize-from-eventlog.d.ts.map +1 -1
  59. package/dist/rematerialize-from-eventlog.js +25 -16
  60. package/dist/rematerialize-from-eventlog.js.map +1 -1
  61. package/dist/schema/EventDef.d.ts +3 -0
  62. package/dist/schema/EventDef.d.ts.map +1 -1
  63. package/dist/schema/EventDef.js.map +1 -1
  64. package/dist/schema/LiveStoreEvent.d.ts +1 -1
  65. package/dist/schema/LiveStoreEvent.d.ts.map +1 -1
  66. package/dist/schema/LiveStoreEvent.js +1 -2
  67. package/dist/schema/LiveStoreEvent.js.map +1 -1
  68. package/dist/schema/mod.d.ts +2 -0
  69. package/dist/schema/mod.d.ts.map +1 -1
  70. package/dist/schema/mod.js +1 -0
  71. package/dist/schema/mod.js.map +1 -1
  72. package/dist/schema/schema.d.ts +15 -0
  73. package/dist/schema/schema.d.ts.map +1 -1
  74. package/dist/schema/schema.js +26 -1
  75. package/dist/schema/schema.js.map +1 -1
  76. package/dist/schema/state/sqlite/client-document-def.d.ts +35 -5
  77. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  78. package/dist/schema/state/sqlite/client-document-def.js +95 -4
  79. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  80. package/dist/schema/state/sqlite/client-document-def.test.js +16 -0
  81. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  82. package/dist/schema/state/sqlite/column-annotations.d.ts.map +1 -1
  83. package/dist/schema/state/sqlite/column-annotations.js +14 -6
  84. package/dist/schema/state/sqlite/column-annotations.js.map +1 -1
  85. package/dist/schema/state/sqlite/column-def.d.ts +6 -2
  86. package/dist/schema/state/sqlite/column-def.d.ts.map +1 -1
  87. package/dist/schema/state/sqlite/column-def.js +122 -185
  88. package/dist/schema/state/sqlite/column-def.js.map +1 -1
  89. package/dist/schema/state/sqlite/column-def.test.js +116 -73
  90. package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
  91. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts +2 -1
  92. package/dist/schema/state/sqlite/db-schema/ast/sqlite.d.ts.map +1 -1
  93. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js +23 -6
  94. package/dist/schema/state/sqlite/db-schema/ast/sqlite.js.map +1 -1
  95. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  96. package/dist/schema/state/sqlite/db-schema/dsl/mod.js +2 -1
  97. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  98. package/dist/schema/state/sqlite/mod.d.ts +1 -1
  99. package/dist/schema/state/sqlite/mod.d.ts.map +1 -1
  100. package/dist/schema/state/sqlite/mod.js +1 -1
  101. package/dist/schema/state/sqlite/mod.js.map +1 -1
  102. package/dist/schema/state/sqlite/query-builder/api.d.ts +5 -2
  103. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  104. package/dist/schema/state/sqlite/query-builder/impl.d.ts.map +1 -1
  105. package/dist/schema/state/sqlite/query-builder/impl.js +6 -2
  106. package/dist/schema/state/sqlite/query-builder/impl.js.map +1 -1
  107. package/dist/schema/state/sqlite/query-builder/impl.test.js +137 -2
  108. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  109. package/dist/schema/state/sqlite/system-tables.d.ts +42 -6
  110. package/dist/schema/state/sqlite/system-tables.d.ts.map +1 -1
  111. package/dist/schema/state/sqlite/system-tables.js +2 -0
  112. package/dist/schema/state/sqlite/system-tables.js.map +1 -1
  113. package/dist/schema/state/sqlite/table-def.d.ts +4 -4
  114. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  115. package/dist/schema/state/sqlite/table-def.js +2 -2
  116. package/dist/schema/state/sqlite/table-def.js.map +1 -1
  117. package/dist/schema/state/sqlite/table-def.test.js +51 -2
  118. package/dist/schema/state/sqlite/table-def.test.js.map +1 -1
  119. package/dist/schema/unknown-events.d.ts +47 -0
  120. package/dist/schema/unknown-events.d.ts.map +1 -0
  121. package/dist/schema/unknown-events.js +69 -0
  122. package/dist/schema/unknown-events.js.map +1 -0
  123. package/dist/sql-queries/sql-query-builder.d.ts.map +1 -1
  124. package/dist/sql-queries/sql-query-builder.js +2 -1
  125. package/dist/sql-queries/sql-query-builder.js.map +1 -1
  126. package/dist/sync/ClientSessionSyncProcessor.d.ts +9 -11
  127. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  128. package/dist/sync/ClientSessionSyncProcessor.js +35 -33
  129. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  130. package/dist/sync/errors.d.ts +61 -0
  131. package/dist/sync/errors.d.ts.map +1 -0
  132. package/dist/sync/errors.js +36 -0
  133. package/dist/sync/errors.js.map +1 -0
  134. package/dist/sync/index.d.ts +3 -0
  135. package/dist/sync/index.d.ts.map +1 -1
  136. package/dist/sync/index.js +3 -0
  137. package/dist/sync/index.js.map +1 -1
  138. package/dist/sync/mock-sync-backend.d.ts +23 -0
  139. package/dist/sync/mock-sync-backend.d.ts.map +1 -0
  140. package/dist/sync/mock-sync-backend.js +114 -0
  141. package/dist/sync/mock-sync-backend.js.map +1 -0
  142. package/dist/sync/next/compact-events.d.ts.map +1 -1
  143. package/dist/sync/next/compact-events.js +4 -5
  144. package/dist/sync/next/compact-events.js.map +1 -1
  145. package/dist/sync/next/facts.d.ts.map +1 -1
  146. package/dist/sync/next/facts.js +1 -2
  147. package/dist/sync/next/facts.js.map +1 -1
  148. package/dist/sync/next/history-dag-common.d.ts +50 -11
  149. package/dist/sync/next/history-dag-common.d.ts.map +1 -1
  150. package/dist/sync/next/history-dag-common.js +193 -4
  151. package/dist/sync/next/history-dag-common.js.map +1 -1
  152. package/dist/sync/next/history-dag.d.ts.map +1 -1
  153. package/dist/sync/next/history-dag.js +3 -1
  154. package/dist/sync/next/history-dag.js.map +1 -1
  155. package/dist/sync/sync-backend-kv.d.ts +7 -0
  156. package/dist/sync/sync-backend-kv.d.ts.map +1 -0
  157. package/dist/sync/sync-backend-kv.js +18 -0
  158. package/dist/sync/sync-backend-kv.js.map +1 -0
  159. package/dist/sync/sync-backend.d.ts +105 -0
  160. package/dist/sync/sync-backend.d.ts.map +1 -0
  161. package/dist/sync/sync-backend.js +61 -0
  162. package/dist/sync/sync-backend.js.map +1 -0
  163. package/dist/sync/sync.d.ts +6 -84
  164. package/dist/sync/sync.d.ts.map +1 -1
  165. package/dist/sync/sync.js +2 -27
  166. package/dist/sync/sync.js.map +1 -1
  167. package/dist/sync/transport-chunking.d.ts +36 -0
  168. package/dist/sync/transport-chunking.d.ts.map +1 -0
  169. package/dist/sync/transport-chunking.js +56 -0
  170. package/dist/sync/transport-chunking.js.map +1 -0
  171. package/dist/sync/validate-push-payload.d.ts +1 -1
  172. package/dist/sync/validate-push-payload.d.ts.map +1 -1
  173. package/dist/sync/validate-push-payload.js +6 -6
  174. package/dist/sync/validate-push-payload.js.map +1 -1
  175. package/dist/testing/event-factory.d.ts +68 -0
  176. package/dist/testing/event-factory.d.ts.map +1 -0
  177. package/dist/testing/event-factory.js +80 -0
  178. package/dist/testing/event-factory.js.map +1 -0
  179. package/dist/testing/mod.d.ts +2 -0
  180. package/dist/testing/mod.d.ts.map +1 -0
  181. package/dist/testing/mod.js +2 -0
  182. package/dist/testing/mod.js.map +1 -0
  183. package/dist/version.d.ts +2 -2
  184. package/dist/version.d.ts.map +1 -1
  185. package/dist/version.js +2 -2
  186. package/dist/version.js.map +1 -1
  187. package/package.json +7 -8
  188. package/src/ClientSessionLeaderThreadProxy.ts +7 -2
  189. package/src/adapter-types.ts +13 -3
  190. package/src/devtools/devtools-messages-common.ts +1 -8
  191. package/src/errors.ts +33 -4
  192. package/src/leader-thread/LeaderSyncProcessor.ts +179 -57
  193. package/src/leader-thread/eventlog.ts +10 -6
  194. package/src/leader-thread/leader-worker-devtools.ts +6 -2
  195. package/src/leader-thread/make-leader-thread-layer.test.ts +44 -0
  196. package/src/leader-thread/make-leader-thread-layer.ts +137 -26
  197. package/src/leader-thread/materialize-event.ts +34 -9
  198. package/src/leader-thread/recreate-db.ts +11 -3
  199. package/src/leader-thread/shutdown-channel.ts +16 -2
  200. package/src/leader-thread/types.ts +7 -5
  201. package/src/materializer-helper.ts +22 -5
  202. package/src/rematerialize-from-eventlog.ts +33 -23
  203. package/src/schema/EventDef.ts +3 -0
  204. package/src/schema/LiveStoreEvent.ts +1 -2
  205. package/src/schema/mod.ts +2 -0
  206. package/src/schema/schema.ts +37 -1
  207. package/src/schema/state/sqlite/client-document-def.test.ts +17 -0
  208. package/src/schema/state/sqlite/client-document-def.ts +117 -5
  209. package/src/schema/state/sqlite/column-annotations.ts +16 -6
  210. package/src/schema/state/sqlite/column-def.test.ts +150 -93
  211. package/src/schema/state/sqlite/column-def.ts +128 -203
  212. package/src/schema/state/sqlite/db-schema/ast/sqlite.ts +26 -6
  213. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +2 -1
  214. package/src/schema/state/sqlite/mod.ts +1 -0
  215. package/src/schema/state/sqlite/query-builder/api.ts +7 -2
  216. package/src/schema/state/sqlite/query-builder/impl.test.ts +187 -6
  217. package/src/schema/state/sqlite/query-builder/impl.ts +8 -2
  218. package/src/schema/state/sqlite/system-tables.ts +2 -0
  219. package/src/schema/state/sqlite/table-def.test.ts +64 -2
  220. package/src/schema/state/sqlite/table-def.ts +9 -8
  221. package/src/schema/unknown-events.ts +131 -0
  222. package/src/sql-queries/sql-query-builder.ts +2 -1
  223. package/src/sync/ClientSessionSyncProcessor.ts +55 -49
  224. package/src/sync/errors.ts +38 -0
  225. package/src/sync/index.ts +3 -0
  226. package/src/sync/mock-sync-backend.ts +184 -0
  227. package/src/sync/next/compact-events.ts +4 -5
  228. package/src/sync/next/facts.ts +1 -3
  229. package/src/sync/next/history-dag-common.ts +272 -21
  230. package/src/sync/next/history-dag.ts +3 -1
  231. package/src/sync/sync-backend-kv.ts +22 -0
  232. package/src/sync/sync-backend.ts +185 -0
  233. package/src/sync/sync.ts +6 -89
  234. package/src/sync/transport-chunking.ts +90 -0
  235. package/src/sync/validate-push-payload.ts +6 -7
  236. package/src/testing/event-factory.ts +133 -0
  237. package/src/testing/mod.ts +1 -0
  238. package/src/version.ts +2 -2
  239. package/dist/schema-management/migrations.test.d.ts +0 -2
  240. package/dist/schema-management/migrations.test.d.ts.map +0 -1
  241. package/dist/schema-management/migrations.test.js +0 -52
  242. package/dist/schema-management/migrations.test.js.map +0 -1
  243. package/dist/sync/next/graphology.d.ts +0 -8
  244. package/dist/sync/next/graphology.d.ts.map +0 -1
  245. package/dist/sync/next/graphology.js +0 -30
  246. package/dist/sync/next/graphology.js.map +0 -1
  247. package/dist/sync/next/graphology_.d.ts +0 -3
  248. package/dist/sync/next/graphology_.d.ts.map +0 -1
  249. package/dist/sync/next/graphology_.js +0 -3
  250. package/dist/sync/next/graphology_.js.map +0 -1
  251. package/src/sync/next/ambient.d.ts +0 -3
  252. package/src/sync/next/graphology.ts +0 -41
  253. package/src/sync/next/graphology_.ts +0 -2
@@ -6,10 +6,14 @@ import { tableIsClientDocumentTable } from './state/sqlite/client-document-def.t
6
6
  import type { SqliteDsl } from './state/sqlite/db-schema/mod.ts'
7
7
  import { stateSystemTables } from './state/sqlite/system-tables.ts'
8
8
  import type { TableDef } from './state/sqlite/table-def.ts'
9
+ import type { UnknownEvents } from './unknown-events.ts'
10
+ import { normalizeUnknownEventHandling } from './unknown-events.ts'
9
11
 
10
12
  export const LiveStoreSchemaSymbol = Symbol.for('livestore.LiveStoreSchema')
11
13
  export type LiveStoreSchemaSymbol = typeof LiveStoreSchemaSymbol
12
14
 
15
+ export const UNKNOWN_EVENT_SCHEMA_HASH = -1
16
+
13
17
  export interface LiveStoreSchema<
14
18
  TDbSchema extends SqliteDsl.DbSchema = SqliteDsl.DbSchema,
15
19
  TEventsDefRecord extends EventDefRecord = EventDefRecord,
@@ -22,6 +26,7 @@ export interface LiveStoreSchema<
22
26
 
23
27
  readonly state: InternalState
24
28
  readonly eventsDefsMap: Map<string, EventDef.AnyWithoutFn>
29
+ readonly unknownEventHandling: UnknownEvents.HandlingConfig
25
30
  readonly devtools: {
26
31
  /** @default 'default' */
27
32
  readonly alias: string
@@ -32,6 +37,30 @@ export namespace LiveStoreSchema {
32
37
  export type Any = LiveStoreSchema<any, any>
33
38
  }
34
39
 
40
+ /**
41
+ * Runtime type guard for LiveStoreSchema.
42
+ *
43
+ * The guard intentionally performs lightweight structural checks that are
44
+ * stable across implementations. It verifies the identifying symbol marker
45
+ * and the presence of core maps/state used at runtime.
46
+ */
47
+ export const isLiveStoreSchema = (value: unknown): value is LiveStoreSchema<any, any> => {
48
+ if (typeof value !== 'object' || value === null) return false
49
+
50
+ const v: any = value
51
+
52
+ // Identity marker must match exactly
53
+ if (v.LiveStoreSchemaSymbol !== LiveStoreSchemaSymbol) return false
54
+
55
+ // Core structures used at runtime
56
+ const hasEventsMap = v.eventsDefsMap instanceof Map
57
+ const hasStateSqliteTables = v.state?.sqlite?.tables instanceof Map
58
+ const hasStateMaterializers = v.state?.materializers instanceof Map
59
+ const hasDevtoolsAlias = typeof v.devtools?.alias === 'string'
60
+
61
+ return hasEventsMap && hasStateSqliteTables && hasStateMaterializers && hasDevtoolsAlias
62
+ }
63
+
35
64
  // TODO abstract this further away from sqlite/tables
36
65
  export interface InternalState {
37
66
  readonly sqlite: {
@@ -55,6 +84,10 @@ export interface InputSchema {
55
84
  */
56
85
  readonly alias?: string
57
86
  }
87
+ /**
88
+ * Configures how unknown events should be handled. Defaults to `{ strategy: 'warn' }`.
89
+ */
90
+ readonly unknownEventHandling?: UnknownEvents.HandlingConfig
58
91
  }
59
92
 
60
93
  export const makeSchema = <TInputSchema extends InputSchema>(
@@ -89,12 +122,15 @@ export const makeSchema = <TInputSchema extends InputSchema>(
89
122
  }
90
123
  }
91
124
 
125
+ const unknownEventHandling = normalizeUnknownEventHandling(inputSchema.unknownEventHandling)
126
+
92
127
  return {
93
128
  LiveStoreSchemaSymbol,
94
129
  _DbSchemaType: Symbol.for('livestore.DbSchemaType') as any,
95
130
  _EventDefMapType: Symbol.for('livestore.EventDefMapType') as any,
96
131
  state,
97
132
  eventsDefsMap,
133
+ unknownEventHandling,
98
134
  devtools: {
99
135
  alias: inputSchema.devtools?.alias ?? 'default',
100
136
  },
@@ -110,7 +146,7 @@ export const getEventDef = <TSchema extends LiveStoreSchema>(
110
146
  } => {
111
147
  const eventDef = schema.eventsDefsMap.get(eventName)
112
148
  if (eventDef === undefined) {
113
- return shouldNeverHappen(`No mutation definition found for \`${eventName}\`.`)
149
+ return shouldNeverHappen(`No event definition found for \`${eventName}\`.`)
114
150
  }
115
151
  const materializer = schema.state.materializers.get(eventName)
116
152
  if (materializer === undefined) {
@@ -50,6 +50,7 @@ describe('client document table', () => {
50
50
  currentFacts: new Map(),
51
51
  query: {} as any, // unused
52
52
  eventDef: Doc[ClientDocumentTableDefSymbol].derived.setEventDef,
53
+ event: {} as any, // unused in this test
53
54
  })
54
55
  }
55
56
 
@@ -230,6 +231,22 @@ describe('client document table', () => {
230
231
  }
231
232
  `)
232
233
  })
234
+
235
+ test('any value (Schema.Any) should fully replace', () => {
236
+ expect(forSchema(Schema.Any, { a: 1 }, 'id1')).toMatchInlineSnapshot(`
237
+ {
238
+ "bindValues": [
239
+ "id1",
240
+ "{"a":1}",
241
+ "{"a":1}",
242
+ ],
243
+ "sql": "INSERT INTO 'test' (id, value) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET value = ?",
244
+ "writeTables": Set {
245
+ "test",
246
+ },
247
+ }
248
+ `)
249
+ })
233
250
  })
234
251
  })
235
252
 
@@ -1,6 +1,6 @@
1
1
  import { shouldNeverHappen } from '@livestore/utils'
2
2
  import type { Option, Types } from '@livestore/utils/effect'
3
- import { Schema, SchemaAST } from '@livestore/utils/effect'
3
+ import { Schema } from '@livestore/utils/effect'
4
4
 
5
5
  import { SessionIdSymbol } from '../../../adapter-types.ts'
6
6
  import { sql } from '../../../util.ts'
@@ -62,9 +62,16 @@ export const clientDocument = <
62
62
  },
63
63
  } satisfies ClientDocumentTableOptions<TType>
64
64
 
65
+ // Column needs optimistic schema to read historical data formats
66
+ const optimisticColumnSchema = createOptimisticEventSchema({
67
+ valueSchema,
68
+ defaultValue: options.default.value,
69
+ partialSet: false, // Column always stores full documents
70
+ })
71
+
65
72
  const columns = {
66
73
  id: SqliteDsl.text({ primaryKey: true }),
67
- value: SqliteDsl.json({ schema: valueSchema }),
74
+ value: SqliteDsl.json({ schema: optimisticColumnSchema }),
68
75
  }
69
76
 
70
77
  const tableDef = table({ name, columns })
@@ -140,6 +147,105 @@ export const mergeDefaultValues = <T>(defaultValues: T, explicitDefaultValues: T
140
147
  }, {} as any)
141
148
  }
142
149
 
150
+ /**
151
+ * Creates an optimistic schema that accepts historical event formats
152
+ * and transforms them to the current schema, preserving data and intent.
153
+ *
154
+ * Decision Matrix for Schema Changes:
155
+ *
156
+ * | Change Type | Partial Set | Full Set | Strategy |
157
+ * |---------------------|---------------------|----------------------------------|-------------------------|
158
+ * | **Compatible Changes** |
159
+ * | Add optional field | Preserve existing | Preserve existing, new field undefined | Direct decode or merge |
160
+ * | Add required field | Preserve existing | Preserve existing, new field from default | Merge with defaults |
161
+ * | **Incompatible Changes** |
162
+ * | Remove field | Drop removed field | Drop removed field, preserve others | Filter & decode |
163
+ * | Type change | Use default for field | Use default for changed field | Selective merge |
164
+ * | Rename field | Use default | Use default (can't detect rename) | Fall back to default |
165
+ * | **Edge Cases** |
166
+ * | Empty event | Return {} | Return full default | Fallback handling |
167
+ * | Invalid structure | Return {} | Return full default | Fallback handling |
168
+ */
169
+ export const createOptimisticEventSchema = ({
170
+ valueSchema,
171
+ defaultValue,
172
+ partialSet,
173
+ }: {
174
+ valueSchema: Schema.Schema<any, any>
175
+ defaultValue: any
176
+ partialSet: boolean
177
+ }) => {
178
+ const targetSchema = partialSet ? Schema.partial(valueSchema) : valueSchema
179
+
180
+ return Schema.transform(
181
+ Schema.Unknown, // Accept any historical event structure
182
+ targetSchema, // Output current schema
183
+ {
184
+ decode: (eventValue) => {
185
+ // Try direct decode first (for current schema events)
186
+ try {
187
+ return Schema.decodeUnknownSync(targetSchema)(eventValue)
188
+ } catch {
189
+ // Optimistic decoding for historical events
190
+
191
+ // Handle null/undefined/non-object cases
192
+ if (typeof eventValue !== 'object' || eventValue === null) {
193
+ console.warn(`Client document: Non-object event value, using ${partialSet ? 'empty partial' : 'defaults'}`)
194
+ return partialSet ? {} : defaultValue
195
+ }
196
+
197
+ if (partialSet) {
198
+ // For partial sets: only preserve fields that exist in new schema
199
+ const partialResult: Record<string, unknown> = {}
200
+ let hasValidFields = false
201
+
202
+ for (const [key, value] of Object.entries(eventValue as Record<string, unknown>)) {
203
+ if (key in defaultValue) {
204
+ partialResult[key] = value
205
+ hasValidFields = true
206
+ }
207
+ // Drop fields that don't exist in new schema
208
+ }
209
+
210
+ if (hasValidFields) {
211
+ try {
212
+ return Schema.decodeUnknownSync(targetSchema)(partialResult)
213
+ } catch {
214
+ // Even filtered fields don't match schema
215
+ console.warn('Client document: Partial fields incompatible, returning empty partial')
216
+ return {}
217
+ }
218
+ }
219
+ return {}
220
+ } else {
221
+ // Full set: merge old data with new defaults
222
+ const merged: Record<string, unknown> = { ...defaultValue }
223
+
224
+ // Override defaults with valid fields from old event
225
+ for (const [key, value] of Object.entries(eventValue as Record<string, unknown>)) {
226
+ if (key in defaultValue) {
227
+ merged[key] = value
228
+ }
229
+ // Drop fields that don't exist in new schema
230
+ }
231
+
232
+ // Try to decode the merged value
233
+ try {
234
+ return Schema.decodeUnknownSync(valueSchema)(merged)
235
+ } catch {
236
+ // Merged value still doesn't match (e.g., type changes)
237
+ // Fall back to pure defaults
238
+ console.warn('Client document: Could not preserve event data, using defaults')
239
+ return defaultValue
240
+ }
241
+ }
242
+ }
243
+ },
244
+ encode: (value) => value, // Pass-through for encoding
245
+ },
246
+ )
247
+ }
248
+
143
249
  export const deriveEventAndMaterializer = ({
144
250
  name,
145
251
  valueSchema,
@@ -155,7 +261,7 @@ export const deriveEventAndMaterializer = ({
155
261
  name: `${name}Set`,
156
262
  schema: Schema.Struct({
157
263
  id: Schema.Union(Schema.String, Schema.UniqueSymbolFromSelf(SessionIdSymbol)),
158
- value: partialSet ? Schema.partial(valueSchema) : valueSchema,
264
+ value: createOptimisticEventSchema({ valueSchema, defaultValue, partialSet }),
159
265
  }).annotations({ title: `${name}Set:Args` }),
160
266
  clientOnly: true,
161
267
  derived: true,
@@ -167,7 +273,7 @@ export const deriveEventAndMaterializer = ({
167
273
  }
168
274
 
169
275
  // Override the full value if it's not an object or no partial set is allowed
170
- const schemaProps = SchemaAST.getPropertySignatures(valueSchema.ast)
276
+ const schemaProps = Schema.getResolvedPropertySignatures(valueSchema)
171
277
  if (schemaProps.length === 0 || partialSet === false) {
172
278
  const valueColJsonSchema = Schema.parseJson(valueSchema)
173
279
  const encodedInsertValue = Schema.encodeSyncDebug(valueColJsonSchema)(value ?? defaultValue)
@@ -281,8 +387,14 @@ export namespace ClientDocumentTableOptions {
281
387
  }
282
388
  }
283
389
 
390
+ type IsStructLike<T> = T extends {} ? true : false
391
+
284
392
  export type WithDefaults<TInput extends Input<any>> = {
285
- partialSet: TInput['partialSet'] extends false ? false : true
393
+ partialSet: TInput['partialSet'] extends false
394
+ ? false
395
+ : IsStructLike<TInput['default']['value']> extends true
396
+ ? true
397
+ : false
286
398
  default: {
287
399
  id: TInput['default']['id'] extends string | SessionIdSymbol ? TInput['default']['id'] : undefined
288
400
  value: TInput['default']['value']
@@ -1,5 +1,5 @@
1
1
  import type { Schema } from '@livestore/utils/effect'
2
- import { dual } from '@livestore/utils/effect'
2
+ import { dual, Option, SchemaAST } from '@livestore/utils/effect'
3
3
  import type { SqliteDsl } from './db-schema/mod.ts'
4
4
 
5
5
  export const PrimaryKeyId = Symbol.for('livestore/state/sqlite/annotations/primary-key')
@@ -32,7 +32,7 @@ Here are the knobs you can turn per-column when you CREATE TABLE (or ALTER TABLE
32
32
  * Adds a primary key annotation to a schema.
33
33
  */
34
34
  export const withPrimaryKey = <T extends Schema.Schema.All>(schema: T) =>
35
- schema.annotations({ [PrimaryKeyId]: true }) as T
35
+ applyAnnotations(schema, { [PrimaryKeyId]: true })
36
36
 
37
37
  /**
38
38
  * Adds a column type annotation to a schema.
@@ -43,19 +43,19 @@ export const withColumnType: {
43
43
  <T extends Schema.Schema.All>(schema: T, type: SqliteDsl.FieldColumnType): T
44
44
  } = dual(2, <T extends Schema.Schema.All>(schema: T, type: SqliteDsl.FieldColumnType) => {
45
45
  validateSchemaColumnTypeCompatibility(schema, type)
46
- return schema.annotations({ [ColumnType]: type }) as T
46
+ return applyAnnotations(schema, { [ColumnType]: type })
47
47
  })
48
48
 
49
49
  /**
50
50
  * Adds an auto-increment annotation to a schema.
51
51
  */
52
52
  export const withAutoIncrement = <T extends Schema.Schema.All>(schema: T) =>
53
- schema.annotations({ [AutoIncrement]: true }) as T
53
+ applyAnnotations(schema, { [AutoIncrement]: true })
54
54
 
55
55
  /**
56
56
  * Adds a unique constraint annotation to a schema.
57
57
  */
58
- export const withUnique = <T extends Schema.Schema.All>(schema: T) => schema.annotations({ [Unique]: true }) as T
58
+ export const withUnique = <T extends Schema.Schema.All>(schema: T) => applyAnnotations(schema, { [Unique]: true })
59
59
 
60
60
  /**
61
61
  * Adds a default value annotation to a schema.
@@ -64,7 +64,7 @@ export const withDefault: {
64
64
  // TODO make type safe
65
65
  <T extends Schema.Schema.All>(schema: T, value: unknown): T
66
66
  (value: unknown): <T extends Schema.Schema.All>(schema: T) => T
67
- } = dual(2, <T extends Schema.Schema.All>(schema: T, value: unknown) => schema.annotations({ [Default]: value }) as T)
67
+ } = dual(2, <T extends Schema.Schema.All>(schema: T, value: unknown) => applyAnnotations(schema, { [Default]: value }))
68
68
 
69
69
  /**
70
70
  * Validates that a schema is compatible with the specified SQLite column type
@@ -75,3 +75,13 @@ const validateSchemaColumnTypeCompatibility = (
75
75
  ): void => {
76
76
  // TODO actually implement this
77
77
  }
78
+
79
+ const applyAnnotations = <T extends Schema.Schema.All>(schema: T, overrides: Record<PropertyKey, unknown>): T => {
80
+ const identifier = SchemaAST.getIdentifierAnnotation(schema.ast)
81
+ const shouldPreserveIdentifier = Option.isSome(identifier) && !(SchemaAST.IdentifierAnnotationId in overrides)
82
+ const annotations: Record<PropertyKey, unknown> = shouldPreserveIdentifier
83
+ ? { ...overrides, [SchemaAST.IdentifierAnnotationId]: identifier.value }
84
+ : overrides
85
+
86
+ return schema.annotations(annotations) as T
87
+ }
@@ -24,6 +24,15 @@ describe('getColumnDefForSchema', () => {
24
24
  it('should map Schema.Date to text column', () => {
25
25
  const columnDef = State.SQLite.getColumnDefForSchema(Schema.Date)
26
26
  expect(columnDef.columnType).toBe('text')
27
+ expect(Schema.encodedSchema(columnDef.schema).toString()).toBe('string')
28
+ expect(Schema.typeSchema(columnDef.schema).toString()).toBe('Date')
29
+ })
30
+
31
+ it('should map Schema.DateFromNumber to integer column', () => {
32
+ const columnDef = State.SQLite.getColumnDefForSchema(Schema.DateFromNumber)
33
+ expect(columnDef.columnType).toBe('integer')
34
+ expect(Schema.encodedSchema(columnDef.schema).toString()).toBe('number')
35
+ expect(Schema.typeSchema(columnDef.schema).toString()).toBe('DateFromSelf')
27
36
  })
28
37
 
29
38
  it('should map Schema.BigInt to text column', () => {
@@ -98,7 +107,7 @@ describe('getColumnDefForSchema', () => {
98
107
  )
99
108
 
100
109
  const columnDef = State.SQLite.getColumnDefForSchema(StringToNumber)
101
- expect(columnDef.columnType).toBe('real') // Based on the target type (Number)
110
+ expect(columnDef.columnType).toBe('text') // Based on the encoded type (String)
102
111
  })
103
112
 
104
113
  it('should handle Date transformations', () => {
@@ -294,6 +303,145 @@ describe('getColumnDefForSchema', () => {
294
303
  })
295
304
  })
296
305
 
306
+ describe('schema-based table definitions', () => {
307
+ it('should handle optional fields in schema', () => {
308
+ const UserSchema = Schema.Struct({
309
+ id: Schema.String,
310
+ name: Schema.String,
311
+ email: Schema.optional(Schema.String),
312
+ age: Schema.optional(Schema.Number),
313
+ })
314
+
315
+ const userTable = State.SQLite.table({
316
+ name: 'users',
317
+ schema: UserSchema,
318
+ })
319
+
320
+ // Optional fields should be nullable
321
+ expect(userTable.sqliteDef.columns.email.nullable).toBe(true)
322
+ expect(userTable.sqliteDef.columns.age.nullable).toBe(true)
323
+
324
+ // Non-optional fields should not be nullable
325
+ expect(userTable.sqliteDef.columns.id.nullable).toBe(false)
326
+ expect(userTable.sqliteDef.columns.name.nullable).toBe(false)
327
+
328
+ // Row schema should show | null for optional fields
329
+ expect((userTable.rowSchema as any).fields.email.toString()).toBe('string | null')
330
+ expect((userTable.rowSchema as any).fields.age.toString()).toBe('number | null')
331
+ })
332
+
333
+ it('should handle optional boolean with proper transformation', () => {
334
+ const schema = Schema.Struct({
335
+ id: Schema.String,
336
+ active: Schema.optional(Schema.Boolean),
337
+ })
338
+
339
+ const table = State.SQLite.table({ name: 'test', schema })
340
+
341
+ expect(table.sqliteDef.columns.active.nullable).toBe(true)
342
+ expect(table.sqliteDef.columns.active.columnType).toBe('integer')
343
+ expect(table.sqliteDef.columns.active.schema.toString()).toBe('(number <-> boolean) | null')
344
+ expect((table.rowSchema as any).fields.active.toString()).toBe('(number <-> boolean) | null')
345
+ })
346
+
347
+ it('should handle optional complex types with JSON encoding', () => {
348
+ const schema = Schema.Struct({
349
+ id: Schema.String,
350
+ metadata: Schema.optional(Schema.Struct({ color: Schema.String })),
351
+ tags: Schema.optional(Schema.Array(Schema.String)),
352
+ })
353
+
354
+ const table = State.SQLite.table({ name: 'test', schema })
355
+
356
+ expect(table.sqliteDef.columns.metadata.nullable).toBe(true)
357
+ expect(table.sqliteDef.columns.metadata.columnType).toBe('text')
358
+ expect((table.rowSchema as any).fields.metadata.toString()).toBe(
359
+ '(parseJson <-> { readonly color: string }) | null',
360
+ // '(parseJson <-> { readonly color: string } | null)', // not sure yet about which semantics we want here
361
+ )
362
+
363
+ expect(table.sqliteDef.columns.tags.nullable).toBe(true)
364
+ expect(table.sqliteDef.columns.tags.columnType).toBe('text')
365
+ expect((table.rowSchema as any).fields.tags.toString()).toBe('(parseJson <-> ReadonlyArray<string>) | null')
366
+ })
367
+
368
+ it('should handle Schema.NullOr', () => {
369
+ const schema = Schema.Struct({
370
+ id: Schema.String,
371
+ description: Schema.NullOr(Schema.String),
372
+ count: Schema.NullOr(Schema.Int),
373
+ })
374
+
375
+ const table = State.SQLite.table({ name: 'test', schema })
376
+
377
+ expect(table.sqliteDef.columns.description.nullable).toBe(true)
378
+ expect(table.sqliteDef.columns.count.nullable).toBe(true)
379
+
380
+ expect((table.rowSchema as any).fields.description.toString()).toBe('string | null')
381
+ expect((table.rowSchema as any).fields.count.toString()).toBe('Int | null')
382
+ })
383
+
384
+ it('should handle Schema.NullOr with complex types', () => {
385
+ const schema = Schema.Struct({
386
+ data: Schema.NullOr(Schema.Struct({ value: Schema.Number })),
387
+ }).annotations({ title: 'test' })
388
+
389
+ const table = State.SQLite.table({ schema })
390
+
391
+ expect(table.sqliteDef.columns.data.nullable).toBe(true)
392
+ expect(table.sqliteDef.columns.data.columnType).toBe('text')
393
+ expect((table.rowSchema as any).fields.data.toString()).toBe('(parseJson <-> { readonly value: number }) | null')
394
+ })
395
+
396
+ it('should handle mixed nullable and optional fields', () => {
397
+ const schema = Schema.Struct({
398
+ nullableText: Schema.NullOr(Schema.String),
399
+ optionalText: Schema.optional(Schema.String),
400
+ optionalJson: Schema.optional(Schema.Struct({ x: Schema.Number })),
401
+ }).annotations({ title: 'test' })
402
+
403
+ const table = State.SQLite.table({ schema })
404
+
405
+ // Both should be nullable at column level
406
+ expect(table.sqliteDef.columns.nullableText.nullable).toBe(true)
407
+ expect(table.sqliteDef.columns.optionalText.nullable).toBe(true)
408
+ expect(table.sqliteDef.columns.optionalJson.nullable).toBe(true)
409
+
410
+ // Schema representations
411
+ expect((table.rowSchema as any).fields.nullableText.toString()).toBe('string | null')
412
+ expect((table.rowSchema as any).fields.optionalText.toString()).toBe('string | null')
413
+ expect((table.rowSchema as any).fields.optionalJson.toString()).toBe(
414
+ '(parseJson <-> { readonly x: number }) | null',
415
+ )
416
+ })
417
+
418
+ // TODO bring back some time later
419
+ // it('should handle lossy Schema.optional(Schema.NullOr(...)) with JSON encoding', () => {
420
+ // const schema = Schema.Struct({
421
+ // id: Schema.String,
422
+ // lossyText: Schema.optional(Schema.NullOr(Schema.String)),
423
+ // lossyComplex: Schema.optional(Schema.NullOr(Schema.Struct({ value: Schema.Number }))),
424
+ // }).annotations({ title: 'lossy_test' })
425
+
426
+ // const table = State.SQLite.table({ schema })
427
+
428
+ // // Check column definitions for lossy fields
429
+ // expect(table.sqliteDef.columns.lossyText.nullable).toBe(true)
430
+ // expect(table.sqliteDef.columns.lossyText.columnType).toBe('text')
431
+ // expect(table.sqliteDef.columns.lossyComplex.nullable).toBe(true)
432
+ // expect(table.sqliteDef.columns.lossyComplex.columnType).toBe('text')
433
+
434
+ // // Check schema representations - should use parseJson for lossy encoding
435
+ // expect((table.rowSchema as any).fields.lossyText.toString()).toBe('(parseJson <-> string | null)')
436
+ // expect((table.rowSchema as any).fields.lossyComplex.toString()).toBe(
437
+ // '(parseJson <-> { readonly value: number } | null)',
438
+ // )
439
+
440
+ // // Note: Since we're converting undefined to null, this is a lossy transformation.
441
+ // // The test now just verifies that the schemas are set up correctly for JSON encoding.
442
+ // })
443
+ })
444
+
297
445
  describe('annotations', () => {
298
446
  describe('withColumnType', () => {
299
447
  it('should respect column type annotation for text', () => {
@@ -345,11 +493,6 @@ describe('getColumnDefForSchema', () => {
345
493
  const UserSchema = Schema.Struct({
346
494
  id: Schema.String.pipe(withPrimaryKey),
347
495
  name: Schema.String,
348
- email: Schema.optional(Schema.String),
349
- nullable: Schema.NullOr(Schema.Int),
350
- optionalComplex: Schema.optional(Schema.Struct({ color: Schema.String })),
351
- optionalNullableText: Schema.optional(Schema.NullOr(Schema.String)),
352
- optionalNullableComplex: Schema.optional(Schema.NullOr(Schema.Struct({ color: Schema.String }))),
353
496
  })
354
497
 
355
498
  const userTable = State.SQLite.table({
@@ -360,93 +503,7 @@ describe('getColumnDefForSchema', () => {
360
503
  expect(userTable.sqliteDef.columns.id.primaryKey).toBe(true)
361
504
  expect(userTable.sqliteDef.columns.id.nullable).toBe(false)
362
505
  expect(userTable.sqliteDef.columns.name.primaryKey).toBe(false)
363
- expect(userTable.sqliteDef.columns.email.primaryKey).toBe(false)
364
- expect(userTable.sqliteDef.columns.email.nullable).toBe(true)
365
- expect(userTable.sqliteDef.columns.nullable.primaryKey).toBe(false)
366
- expect(userTable.sqliteDef.columns.nullable.nullable).toBe(true)
367
- expect(userTable.sqliteDef.columns.optionalComplex.nullable).toBe(true)
368
- expect((userTable.rowSchema as any).fields.email.toString()).toBe('string | undefined')
369
- expect((userTable.rowSchema as any).fields.nullable.toString()).toBe('Int | null')
370
- expect((userTable.rowSchema as any).fields.optionalComplex.toString()).toBe(
371
- '(parseJson <-> { readonly color: string } | undefined)',
372
- )
373
- })
374
-
375
- it('should handle Schema.NullOr with complex types', () => {
376
- const schema = Schema.Struct({
377
- data: Schema.NullOr(Schema.Struct({ value: Schema.Number })),
378
- }).annotations({ title: 'test' })
379
-
380
- const table = State.SQLite.table({ schema })
381
-
382
- expect(table.sqliteDef.columns.data.nullable).toBe(true)
383
- expect(table.sqliteDef.columns.data.columnType).toBe('text')
384
- expect((table.rowSchema as any).fields.data.toString()).toBe('{ readonly value: number } | null')
385
- })
386
-
387
- it('should handle mixed nullable and optional fields', () => {
388
- const schema = Schema.Struct({
389
- nullableText: Schema.NullOr(Schema.String),
390
- optionalText: Schema.optional(Schema.String),
391
- optionalJson: Schema.optional(Schema.Struct({ x: Schema.Number })),
392
- }).annotations({ title: 'test' })
393
-
394
- const table = State.SQLite.table({ schema })
395
-
396
- // Both should be nullable at column level
397
- expect(table.sqliteDef.columns.nullableText.nullable).toBe(true)
398
- expect(table.sqliteDef.columns.optionalText.nullable).toBe(true)
399
- expect(table.sqliteDef.columns.optionalJson.nullable).toBe(true)
400
-
401
- // But different schema representations
402
- expect((table.rowSchema as any).fields.nullableText.toString()).toBe('string | null')
403
- expect((table.rowSchema as any).fields.optionalText.toString()).toBe('string | undefined')
404
- expect((table.rowSchema as any).fields.optionalJson.toString()).toBe(
405
- '(parseJson <-> { readonly x: number } | undefined)',
406
- )
407
- })
408
-
409
- it('should handle lossy Schema.optional(Schema.NullOr(...)) with JSON encoding', () => {
410
- const schema = Schema.Struct({
411
- id: Schema.String,
412
- lossyText: Schema.optional(Schema.NullOr(Schema.String)),
413
- lossyComplex: Schema.optional(Schema.NullOr(Schema.Struct({ value: Schema.Number }))),
414
- }).annotations({ title: 'lossy_test' })
415
-
416
- const table = State.SQLite.table({ schema })
417
-
418
- // Check column definitions for lossy fields
419
- expect(table.sqliteDef.columns.lossyText.nullable).toBe(true)
420
- expect(table.sqliteDef.columns.lossyText.columnType).toBe('text')
421
- expect(table.sqliteDef.columns.lossyComplex.nullable).toBe(true)
422
- expect(table.sqliteDef.columns.lossyComplex.columnType).toBe('text')
423
-
424
- // Check schema representations - should use parseJson for lossless encoding
425
- expect((table.rowSchema as any).fields.lossyText.toString()).toBe('(parseJson <-> string | null | undefined)')
426
- expect((table.rowSchema as any).fields.lossyComplex.toString()).toBe(
427
- '(parseJson <-> { readonly value: number } | null | undefined)',
428
- )
429
-
430
- // Test actual data round-tripping to ensure losslessness
431
- // Note: Missing field case is challenging with current Effect Schema design
432
- // as optional fields are handled at struct level, not field level
433
- const testCases = [
434
- // For now, test only cases where both lossy fields are present
435
- { name: 'both explicit null', data: { id: '2', lossyText: null, lossyComplex: null } },
436
- { name: 'text value, complex null', data: { id: '3', lossyText: 'hello', lossyComplex: null } },
437
- { name: 'text null, complex value', data: { id: '4', lossyText: null, lossyComplex: { value: 42 } } },
438
- { name: 'both values', data: { id: '5', lossyText: 'world', lossyComplex: { value: 42 } } },
439
- ]
440
-
441
- testCases.forEach((testCase) => {
442
- // Encode through insert schema
443
- const encoded = Schema.encodeSync(table.insertSchema)(testCase.data)
444
- // Decode through row schema
445
- const decoded = Schema.decodeSync(table.rowSchema)(encoded)
446
-
447
- // Check for losslessness
448
- expect(decoded).toEqual(testCase.data)
449
- })
506
+ expect(userTable.sqliteDef.columns.name.nullable).toBe(false)
450
507
  })
451
508
 
452
509
  it('should throw when primary key is used with optional schema', () => {