@livestore/common 0.4.0-dev.20 → 0.4.0-dev.22

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 (127) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ClientSessionLeaderThreadProxy.d.ts +10 -0
  3. package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
  4. package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
  5. package/dist/adapter-types.d.ts +23 -0
  6. package/dist/adapter-types.d.ts.map +1 -1
  7. package/dist/adapter-types.js +27 -1
  8. package/dist/adapter-types.js.map +1 -1
  9. package/dist/devtools/devtools-messages-client-session.d.ts +42 -22
  10. package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
  11. package/dist/devtools/devtools-messages-client-session.js +12 -1
  12. package/dist/devtools/devtools-messages-client-session.js.map +1 -1
  13. package/dist/devtools/devtools-messages-common.d.ts +12 -6
  14. package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
  15. package/dist/devtools/devtools-messages-common.js +7 -2
  16. package/dist/devtools/devtools-messages-common.js.map +1 -1
  17. package/dist/devtools/devtools-messages-leader.d.ts +47 -25
  18. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  19. package/dist/devtools/devtools-messages-leader.js +13 -1
  20. package/dist/devtools/devtools-messages-leader.js.map +1 -1
  21. package/dist/leader-thread/LeaderSyncProcessor.d.ts +33 -0
  22. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  23. package/dist/leader-thread/LeaderSyncProcessor.js +12 -12
  24. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  25. package/dist/leader-thread/eventlog.d.ts +6 -1
  26. package/dist/leader-thread/eventlog.d.ts.map +1 -1
  27. package/dist/leader-thread/eventlog.js +59 -2
  28. package/dist/leader-thread/eventlog.js.map +1 -1
  29. package/dist/leader-thread/leader-worker-devtools.js +38 -6
  30. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  31. package/dist/leader-thread/make-leader-thread-layer.d.ts +4 -2
  32. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  33. package/dist/leader-thread/make-leader-thread-layer.js +5 -1
  34. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  35. package/dist/leader-thread/materialize-event.d.ts.map +1 -1
  36. package/dist/leader-thread/materialize-event.js +3 -0
  37. package/dist/leader-thread/materialize-event.js.map +1 -1
  38. package/dist/leader-thread/mod.d.ts +1 -0
  39. package/dist/leader-thread/mod.d.ts.map +1 -1
  40. package/dist/leader-thread/mod.js +1 -0
  41. package/dist/leader-thread/mod.js.map +1 -1
  42. package/dist/leader-thread/stream-events.d.ts +56 -0
  43. package/dist/leader-thread/stream-events.d.ts.map +1 -0
  44. package/dist/leader-thread/stream-events.js +166 -0
  45. package/dist/leader-thread/stream-events.js.map +1 -0
  46. package/dist/leader-thread/types.d.ts +77 -1
  47. package/dist/leader-thread/types.d.ts.map +1 -1
  48. package/dist/leader-thread/types.js +13 -0
  49. package/dist/leader-thread/types.js.map +1 -1
  50. package/dist/otel.d.ts +2 -1
  51. package/dist/otel.d.ts.map +1 -1
  52. package/dist/otel.js +5 -0
  53. package/dist/otel.js.map +1 -1
  54. package/dist/schema/EventDef/define.d.ts +14 -0
  55. package/dist/schema/EventDef/define.d.ts.map +1 -1
  56. package/dist/schema/EventDef/define.js +1 -0
  57. package/dist/schema/EventDef/define.js.map +1 -1
  58. package/dist/schema/EventDef/deprecated.d.ts +99 -0
  59. package/dist/schema/EventDef/deprecated.d.ts.map +1 -0
  60. package/dist/schema/EventDef/deprecated.js +144 -0
  61. package/dist/schema/EventDef/deprecated.js.map +1 -0
  62. package/dist/schema/EventDef/deprecated.test.d.ts +2 -0
  63. package/dist/schema/EventDef/deprecated.test.d.ts.map +1 -0
  64. package/dist/schema/EventDef/deprecated.test.js +95 -0
  65. package/dist/schema/EventDef/deprecated.test.js.map +1 -0
  66. package/dist/schema/EventDef/event-def.d.ts +4 -0
  67. package/dist/schema/EventDef/event-def.d.ts.map +1 -1
  68. package/dist/schema/EventDef/mod.d.ts +1 -0
  69. package/dist/schema/EventDef/mod.d.ts.map +1 -1
  70. package/dist/schema/EventDef/mod.js +1 -0
  71. package/dist/schema/EventDef/mod.js.map +1 -1
  72. package/dist/schema/LiveStoreEvent/client.d.ts +6 -6
  73. package/dist/schema/state/sqlite/client-document-def.d.ts +1 -0
  74. package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
  75. package/dist/schema/state/sqlite/client-document-def.js +17 -8
  76. package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
  77. package/dist/schema/state/sqlite/client-document-def.test.js +120 -1
  78. package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
  79. package/dist/schema/state/sqlite/column-def.test.js +2 -3
  80. package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
  81. package/dist/schema/state/sqlite/db-schema/dsl/mod.d.ts.map +1 -1
  82. package/dist/schema/state/sqlite/db-schema/dsl/mod.js.map +1 -1
  83. package/dist/schema/state/sqlite/query-builder/api.d.ts +29 -12
  84. package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
  85. package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
  86. package/dist/schema/state/sqlite/query-builder/astToSql.js +71 -1
  87. package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
  88. package/dist/schema/state/sqlite/query-builder/impl.test.js +109 -1
  89. package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
  90. package/dist/schema/state/sqlite/table-def.d.ts.map +1 -1
  91. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  92. package/dist/sync/ClientSessionSyncProcessor.js +6 -2
  93. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  94. package/dist/version.d.ts +7 -1
  95. package/dist/version.d.ts.map +1 -1
  96. package/dist/version.js +8 -1
  97. package/dist/version.js.map +1 -1
  98. package/package.json +4 -4
  99. package/src/ClientSessionLeaderThreadProxy.ts +10 -0
  100. package/src/adapter-types.ts +30 -0
  101. package/src/devtools/devtools-messages-client-session.ts +12 -0
  102. package/src/devtools/devtools-messages-common.ts +7 -3
  103. package/src/devtools/devtools-messages-leader.ts +13 -0
  104. package/src/leader-thread/LeaderSyncProcessor.ts +116 -42
  105. package/src/leader-thread/eventlog.ts +80 -4
  106. package/src/leader-thread/leader-worker-devtools.ts +52 -6
  107. package/src/leader-thread/make-leader-thread-layer.ts +8 -0
  108. package/src/leader-thread/materialize-event.ts +4 -0
  109. package/src/leader-thread/mod.ts +1 -0
  110. package/src/leader-thread/stream-events.ts +201 -0
  111. package/src/leader-thread/types.ts +49 -1
  112. package/src/otel.ts +10 -0
  113. package/src/schema/EventDef/define.ts +16 -0
  114. package/src/schema/EventDef/deprecated.test.ts +128 -0
  115. package/src/schema/EventDef/deprecated.ts +175 -0
  116. package/src/schema/EventDef/event-def.ts +5 -0
  117. package/src/schema/EventDef/mod.ts +1 -0
  118. package/src/schema/state/sqlite/client-document-def.test.ts +140 -2
  119. package/src/schema/state/sqlite/client-document-def.ts +25 -26
  120. package/src/schema/state/sqlite/column-def.test.ts +2 -3
  121. package/src/schema/state/sqlite/db-schema/dsl/mod.ts +10 -16
  122. package/src/schema/state/sqlite/query-builder/api.ts +31 -4
  123. package/src/schema/state/sqlite/query-builder/astToSql.ts +81 -1
  124. package/src/schema/state/sqlite/query-builder/impl.test.ts +141 -1
  125. package/src/schema/state/sqlite/table-def.ts +9 -8
  126. package/src/sync/ClientSessionSyncProcessor.ts +26 -13
  127. package/src/version.ts +9 -1
@@ -0,0 +1,201 @@
1
+ import type { Subscribable } from '@livestore/utils/effect'
2
+ import { Chunk, Effect, Option, Queue, Stream } from '@livestore/utils/effect'
3
+ import { EventSequenceNumber, type LiveStoreEvent } from '../schema/mod.ts'
4
+ import type * as SyncState from '../sync/syncstate.ts'
5
+ import * as Eventlog from './eventlog.ts'
6
+ import type { LeaderSqliteDb, StreamEventsOptions } from './types.ts'
7
+
8
+ /**
9
+ * Streams events for leader-thread adapters.
10
+ *
11
+ * Provides a continuous stream from the eventlog as the upstream head advances.
12
+ * When an until event is passed in the stream finalizes upon reaching it.
13
+ *
14
+ * The batch size is set to 100 by default as this was meassured to provide the
15
+ * best performance and 1000 as the upper limit.
16
+ *
17
+ * Adapters that call this helper:
18
+ * - `packages/@livestore/adapter-web/src/in-memory/in-memory-adapter.ts`
19
+ * - `packages/@livestore/adapter-web/src/web-worker/leader-worker/make-leader-worker.ts`
20
+ * - `packages/@livestore/adapter-node/src/client-session/adapter.ts`
21
+ * - `packages/@livestore/adapter-node/src/make-leader-worker.ts`
22
+ * - `packages/@livestore/adapter-cloudflare/src/make-adapter.ts`
23
+ *
24
+ * Each caller resolves dependencies inside the leader scope before invoking this helper,
25
+ * so the stream stays environment-agnostic and does not leak `LeaderThreadCtx` into runtime
26
+ * entry points such as `Store.eventsStream`.
27
+ *
28
+ * Test files:
29
+ * Unit: `tests/package-common/src/leader-thread/stream-events.test.ts`
30
+ * Integration: `packages/@livestore/livestore/src/store/store-eventstream.test.ts`
31
+ * Performance: `tests/perf-eventlog/tests/suites/event-streaming.test.ts`
32
+ *
33
+ * Optimization explorations
34
+ *
35
+ * In order to alleviate the occurence of many small queries when the syncState
36
+ * is sequentially progressing quickly we have explored some time-based batching
37
+ * approaches. It remains to be determined if and when the added complexity of
38
+ * these approaches are worth the benefit. They come with some drawbacks such as
39
+ * degraded time to first event or general performance degredation for larger
40
+ * query steps. These aspects can likely be mitigated with some more work but
41
+ * that is best assessed when we have a final implementation of event streaming
42
+ * with support for session and leader level streams.
43
+ *
44
+ * Fetch plans into a Sink
45
+ * https://gist.github.com/slashv/f1223689f2d1171d2eeb60a2823f4c7c
46
+ *
47
+ * Fetch plans into sink and decompose into windows
48
+ * https://gist.github.com/slashv/a8f55f50121c080937f42e44b4039ac8
49
+ *
50
+ * Mailbox and Latch approach (suggestion by Tim Smart)
51
+ * https://gist.github.com/slashv/d6b12395c85415bf0d3363372a1636c3
52
+ */
53
+ export const streamEventsWithSyncState = ({
54
+ dbEventlog,
55
+ syncState,
56
+ options,
57
+ }: {
58
+ dbEventlog: LeaderSqliteDb
59
+ syncState: Subscribable.Subscribable<SyncState.SyncState>
60
+ options: StreamEventsOptions
61
+ }): Stream.Stream<LiveStoreEvent.Client.Encoded> => {
62
+ const initialCursor = options.since ?? EventSequenceNumber.Client.ROOT
63
+ const batchSize = options.batchSize ?? 100
64
+
65
+ return Stream.unwrapScoped(
66
+ Effect.gen(function* () {
67
+ /**
68
+ * Single-element Queue allows suspending the event stream until head
69
+ * advances because Queue.take is a suspending effect. SubscriptionRef in
70
+ * comparrison lacks a primitive for suspending a stream until a new value
71
+ * is set and would require polling.
72
+ *
73
+ * The use of a sliding Queue here is useful since it ensures only the
74
+ * lastest head from syncState is the one present on the queue without the
75
+ * need for manual substitution.
76
+ */
77
+ const headQueue = yield* Queue.sliding<EventSequenceNumber.Client.Composite>(1)
78
+
79
+ /**
80
+ * We run a separate fiber which listens to changes in syncState and
81
+ * offer the latest head to the headQueue. Keeping track of the previous
82
+ * value is done to prevent syncState changes unrelated to the
83
+ * upstreamHead triggering empty queries.
84
+ *
85
+ * When we implement support for leader and session level streams
86
+ * this will need to be adapted to support the relevant value from
87
+ * syncState that we are interested in tracking.
88
+ */
89
+ let prevGlobalHead = -1
90
+ yield* syncState.changes.pipe(
91
+ Stream.map((state) => state.upstreamHead),
92
+ Stream.filter((head) => {
93
+ if (head.global > prevGlobalHead) {
94
+ prevGlobalHead = head.global
95
+ return true
96
+ }
97
+ return false
98
+ }),
99
+ Stream.runForEach((head) => Queue.offer(headQueue, head)),
100
+ Effect.forkScoped,
101
+ )
102
+
103
+ return Stream.paginateChunkEffect(
104
+ { cursor: initialCursor, head: EventSequenceNumber.Client.ROOT },
105
+ ({ cursor, head }) =>
106
+ Effect.gen(function* () {
107
+ /**
108
+ * Early check guards agains:
109
+ * since === until : Prevent empty query
110
+ * since > until : Incorrectly inverted interval
111
+ */
112
+ if (options.until && EventSequenceNumber.Client.isGreaterThanOrEqual(cursor, options.until)) {
113
+ return [Chunk.empty(), Option.none()]
114
+ }
115
+
116
+ /**
117
+ * There are two scenarios where we take the next head from the headQueue:
118
+ *
119
+ * 1. We need to wait for the head to advance
120
+ * The Stream suspends until a new head is available on the headQueue
121
+ *
122
+ * 2. Head has advanced during itteration
123
+ * While itterating towards the lastest head taken from the headQueue
124
+ * in increments of batchSize it's possible the head could have
125
+ * advanced. This leads to a suboptimal amount of queries. Therefor we
126
+ * check if the headQueue is full which tells us that there's a new
127
+ * head available to take. Example:
128
+ *
129
+ * batchSize: 2
130
+ *
131
+ * --> head at: e3
132
+ * First query: e0 -> e2 (two events)
133
+ * --> head advances to: e4
134
+ * Second query: e2 -> e3 (one event but we could have taken 2)
135
+ * --> Take the new head of e4
136
+ * Third query: e3 -> e4 (unnecessary third query)
137
+ *
138
+ *
139
+ * To define the target, which will be used as the temporary until
140
+ * marker for the eventlog query, we select the lowest of three possible values:
141
+ *
142
+ * hardStop: A user supplied until marker
143
+ * current cursor + batchSize: A batchSize step towards the latest head from headQueue
144
+ * nextHead: The latest head from headQueue
145
+ */
146
+ const waitForHead = EventSequenceNumber.Client.isGreaterThanOrEqual(cursor, head)
147
+ const maybeHead = waitForHead
148
+ ? yield* Queue.take(headQueue).pipe(Effect.map(Option.some))
149
+ : yield* Queue.poll(headQueue)
150
+ const nextHead = Option.getOrElse(maybeHead, () => head)
151
+ const hardStop = options.until?.global ?? Number.POSITIVE_INFINITY
152
+ const target = EventSequenceNumber.Client.Composite.make({
153
+ global: Math.min(hardStop, cursor.global + batchSize, nextHead.global),
154
+ client: EventSequenceNumber.Client.DEFAULT,
155
+ })
156
+
157
+ /**
158
+ * Eventlog.getEventsFromEventlog returns a Chunk from each
159
+ * query which is what we emit at each itteration.
160
+ */
161
+ const chunk = yield* Eventlog.getEventsFromEventlog({
162
+ dbEventlog,
163
+ options: {
164
+ ...options,
165
+ since: cursor,
166
+ until: target,
167
+ },
168
+ })
169
+
170
+ /**
171
+ * We construct the state for the following itteration of the stream
172
+ * loop by setting the current target as the since cursor and pass
173
+ * along the latest head.
174
+ *
175
+ * If we have the reached the user supplied until marker we signal the
176
+ * finalization of the stream by passing Option.none() instead.
177
+ */
178
+ const reachedUntil =
179
+ options.until !== undefined && EventSequenceNumber.Client.isGreaterThanOrEqual(target, options.until)
180
+
181
+ const nextState: Option.Option<{
182
+ cursor: EventSequenceNumber.Client.Composite
183
+ head: EventSequenceNumber.Client.Composite
184
+ }> = reachedUntil ? Option.none() : Option.some({ cursor: target, head: nextHead })
185
+
186
+ const spanAttributes = {
187
+ 'livestore.streamEvents.cursor.global': cursor.global,
188
+ 'livestore.streamEvents.target.global': target.global,
189
+ 'livestore.streamEvents.batchSize': batchSize,
190
+ 'livestore.streamEvents.waitedForHead': waitForHead,
191
+ }
192
+
193
+ return yield* Effect.succeed<[Chunk.Chunk<LiveStoreEvent.Client.Encoded>, typeof nextState]>([
194
+ chunk,
195
+ nextState,
196
+ ]).pipe(Effect.withSpan('@livestore/common:streamEvents:segment', { attributes: spanAttributes }))
197
+ }),
198
+ )
199
+ }),
200
+ )
201
+ }
@@ -24,7 +24,7 @@ import type {
24
24
  SyncBackend,
25
25
  UnknownError,
26
26
  } from '../index.ts'
27
- import type { EventSequenceNumber, LiveStoreEvent, LiveStoreSchema } from '../schema/mod.ts'
27
+ import { EventSequenceNumber, type LiveStoreEvent, type LiveStoreSchema } from '../schema/mod.ts'
28
28
  import type * as SyncState from '../sync/syncstate.ts'
29
29
  import type { ShutdownChannel } from './shutdown-channel.ts'
30
30
 
@@ -134,6 +134,54 @@ export type InitialBlockingSyncContext = {
134
134
  update: (_: { pageInfo: SyncBackend.PullResPageInfo; processed: number }) => Effect.Effect<void>
135
135
  }
136
136
 
137
+ export const STREAM_EVENTS_BATCH_SIZE_DEFAULT = 100
138
+ export const STREAM_EVENTS_BATCH_SIZE_MAX = 1_000
139
+
140
+ export const StreamEventsOptionsFields = {
141
+ since: Schema.optional(EventSequenceNumber.Client.Composite),
142
+ until: Schema.optional(EventSequenceNumber.Client.Composite),
143
+ filter: Schema.optional(Schema.Array(Schema.String)),
144
+ clientIds: Schema.optional(Schema.Array(Schema.String)),
145
+ sessionIds: Schema.optional(Schema.Array(Schema.String)),
146
+ batchSize: Schema.optional(Schema.Int.pipe(Schema.between(1, STREAM_EVENTS_BATCH_SIZE_MAX))),
147
+ includeClientOnly: Schema.optional(Schema.Boolean),
148
+ } as const
149
+
150
+ export const StreamEventsOptionsSchema = Schema.Struct(StreamEventsOptionsFields)
151
+
152
+ export interface StreamEventsOptions {
153
+ /**
154
+ * Only include events after this logical timestamp (exclusive).
155
+ * Defaults to `EventSequenceNumber.Client.ROOT` when omitted.
156
+ */
157
+ since?: EventSequenceNumber.Client.Composite
158
+ /**
159
+ * Only include events up to this logical timestamp (inclusive).
160
+ */
161
+ until?: EventSequenceNumber.Client.Composite
162
+ /**
163
+ * Only include events of the given names.
164
+ */
165
+ filter?: ReadonlyArray<string>
166
+ /**
167
+ * Only include events from specific client identifiers.
168
+ */
169
+ clientIds?: ReadonlyArray<string>
170
+ /**
171
+ * Only include events from specific session identifiers.
172
+ */
173
+ sessionIds?: ReadonlyArray<string>
174
+ /**
175
+ * Number of events to fetch in each batch when streaming from the eventlog.
176
+ * Defaults to 100.
177
+ */
178
+ batchSize?: number
179
+ /**
180
+ * Include client-only events (i.e. events with a positive client sequence number).
181
+ */
182
+ includeClientOnly?: boolean
183
+ }
184
+
137
185
  export interface LeaderSyncProcessor {
138
186
  /** Used by client sessions to subscribe to upstream sync state changes */
139
187
  pull: (args: {
package/src/otel.ts CHANGED
@@ -2,6 +2,16 @@ import { makeNoopTracer } from '@livestore/utils'
2
2
  import { Effect, identity, Layer, OtelTracer } from '@livestore/utils/effect'
3
3
  import * as otel from '@opentelemetry/api'
4
4
 
5
+ export const OtelLiveDummy: Layer.Layer<OtelTracer.OtelTracer> = Layer.suspend(() => {
6
+ const OtelTracerLive = Layer.succeed(OtelTracer.OtelTracer, makeNoopTracer())
7
+
8
+ const TracingLive = Layer.unwrapEffect(Effect.map(OtelTracer.make, Layer.setTracer)).pipe(
9
+ Layer.provideMerge(OtelTracerLive),
10
+ )
11
+
12
+ return TracingLive
13
+ })
14
+
5
15
  export const provideOtel =
6
16
  ({ otelTracer, parentSpanContext }: { otelTracer?: otel.Tracer; parentSpanContext?: otel.Context }) =>
7
17
  <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, OtelTracer.OtelTracer>> => {
@@ -69,6 +69,21 @@ export type DefineEventOptions<TTo, TDerived extends boolean = false> = {
69
69
  * @default false
70
70
  */
71
71
  derived?: TDerived
72
+
73
+ /**
74
+ * Marks the entire event as deprecated with a reason message.
75
+ * When this event is committed, a warning will be logged.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * Events.synced({
80
+ * name: 'v1.TodoRenamed',
81
+ * schema: Schema.Struct({ id: Schema.String, name: Schema.String }),
82
+ * deprecated: "Use 'v1.TodoUpdated' instead",
83
+ * })
84
+ * ```
85
+ */
86
+ deprecated?: string
72
87
  }
73
88
 
74
89
  /**
@@ -125,6 +140,7 @@ export const defineEvent = <TName extends string, TType, TEncoded = TType, TDeri
125
140
  }
126
141
  : undefined,
127
142
  derived: options?.derived ?? false,
143
+ deprecated: options?.deprecated,
128
144
  } satisfies EventDef.Any['options'],
129
145
  })
130
146
 
@@ -0,0 +1,128 @@
1
+ import { Effect, Logger, Schema } from '@livestore/utils/effect'
2
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
3
+
4
+ import { synced } from './define.ts'
5
+ import {
6
+ deprecated,
7
+ findDeprecatedFieldsWithValues,
8
+ getDeprecatedReason,
9
+ isDeprecated,
10
+ logDeprecationWarnings,
11
+ resetDeprecationWarnings,
12
+ } from './deprecated.ts'
13
+
14
+ describe('deprecated annotations', () => {
15
+ test('adds deprecation annotation to schema', () => {
16
+ const schema = Schema.String.pipe(deprecated('Use newField instead'))
17
+ expect(isDeprecated(schema)).toBe(true)
18
+ expect(getDeprecatedReason(schema)._tag).toBe('Some')
19
+ })
20
+
21
+ test('works with optional fields in Struct', () => {
22
+ const struct = Schema.Struct({
23
+ oldField: Schema.optional(Schema.String).pipe(deprecated('Legacy')),
24
+ })
25
+ expect(findDeprecatedFieldsWithValues(struct, { oldField: 'x' })).toEqual([{ field: 'oldField', reason: 'Legacy' }])
26
+ })
27
+
28
+ test('non-deprecated schemas return false', () => {
29
+ expect(isDeprecated(Schema.String)).toBe(false)
30
+ })
31
+
32
+ test('ignores deprecated fields without values', () => {
33
+ const schema = Schema.Struct({
34
+ id: Schema.String,
35
+ old: Schema.optional(Schema.String).pipe(deprecated('x')),
36
+ })
37
+ expect(findDeprecatedFieldsWithValues(schema, { id: '1' })).toEqual([])
38
+ })
39
+
40
+ test('finds multiple deprecated fields', () => {
41
+ const schema = Schema.Struct({
42
+ a: Schema.optional(Schema.String).pipe(deprecated('A')),
43
+ b: Schema.optional(Schema.String).pipe(deprecated('B')),
44
+ })
45
+ const result = findDeprecatedFieldsWithValues(schema, { a: '1', b: '2' })
46
+ expect(result).toHaveLength(2)
47
+ })
48
+ })
49
+
50
+ describe('logDeprecationWarnings', () => {
51
+ let logs: unknown[][]
52
+
53
+ beforeEach(() => {
54
+ resetDeprecationWarnings()
55
+ logs = []
56
+ })
57
+
58
+ afterEach(() => resetDeprecationWarnings())
59
+
60
+ const run = (effect: Effect.Effect<void>) =>
61
+ Effect.runSync(
62
+ effect.pipe(
63
+ Effect.provide(
64
+ Logger.replace(
65
+ Logger.defaultLogger,
66
+ Logger.make(({ message }) => logs.push(message as unknown[])),
67
+ ),
68
+ ),
69
+ ),
70
+ )
71
+
72
+ test('logs event deprecation warning', () => {
73
+ const event = synced({ name: 'Old', schema: Schema.Struct({ id: Schema.String }), deprecated: 'Use New' })
74
+ run(logDeprecationWarnings(event, { id: '1' }))
75
+ expect(logs).toEqual([['@livestore/schema:deprecated-event', { event: 'Old', reason: 'Use New' }]])
76
+ })
77
+
78
+ test('logs field deprecation warning', () => {
79
+ const event = synced({
80
+ name: 'Ev',
81
+ schema: Schema.Struct({ old: Schema.optional(Schema.String).pipe(deprecated('Use new')) }),
82
+ })
83
+ run(logDeprecationWarnings(event, { old: 'x' }))
84
+ expect(logs).toEqual([['@livestore/schema:deprecated-field', { event: 'Ev', field: 'old', reason: 'Use new' }]])
85
+ })
86
+
87
+ test('deduplicates event warnings', () => {
88
+ const event = synced({ name: 'Dup', schema: Schema.Struct({ id: Schema.String }), deprecated: 'x' })
89
+ run(logDeprecationWarnings(event, { id: '1' }))
90
+ run(logDeprecationWarnings(event, { id: '2' }))
91
+ expect(logs).toHaveLength(1)
92
+ })
93
+
94
+ test('deduplicates field warnings', () => {
95
+ const event = synced({
96
+ name: 'DupField',
97
+ schema: Schema.Struct({ old: Schema.optional(Schema.String).pipe(deprecated('x')) }),
98
+ })
99
+ run(logDeprecationWarnings(event, { old: 'a' }))
100
+ run(logDeprecationWarnings(event, { old: 'b' }))
101
+ expect(logs).toHaveLength(1)
102
+ })
103
+
104
+ test('no warning for non-deprecated event', () => {
105
+ const event = synced({ name: 'Normal', schema: Schema.Struct({ id: Schema.String }) })
106
+ run(logDeprecationWarnings(event, { id: '1' }))
107
+ expect(logs).toHaveLength(0)
108
+ })
109
+
110
+ test('no warning when deprecated field is undefined', () => {
111
+ const event = synced({
112
+ name: 'Unused',
113
+ schema: Schema.Struct({ old: Schema.optional(Schema.String).pipe(deprecated('x')) }),
114
+ })
115
+ run(logDeprecationWarnings(event, {}))
116
+ expect(logs).toHaveLength(0)
117
+ })
118
+
119
+ test('logs both event and field warnings', () => {
120
+ const event = synced({
121
+ name: 'Both',
122
+ schema: Schema.Struct({ old: Schema.optional(Schema.String).pipe(deprecated('F')) }),
123
+ deprecated: 'E',
124
+ })
125
+ run(logDeprecationWarnings(event, { old: 'x' }))
126
+ expect(logs).toHaveLength(2)
127
+ })
128
+ })
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Deprecation Annotations for Events
3
+ *
4
+ * This module provides utilities for marking event fields and entire events as deprecated.
5
+ * When a deprecated field is used or a deprecated event is committed, a warning is logged.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { Events } from '@livestore/livestore'
10
+ * import { Schema } from 'effect'
11
+ * import { deprecated } from '@livestore/common/schema'
12
+ *
13
+ * // Field-level deprecation
14
+ * const todoUpdated = Events.synced({
15
+ * name: 'v1.TodoUpdated',
16
+ * schema: Schema.Struct({
17
+ * id: Schema.String,
18
+ * title: Schema.optional(Schema.String).pipe(deprecated("Use 'text' instead")),
19
+ * text: Schema.optional(Schema.String),
20
+ * }),
21
+ * })
22
+ *
23
+ * // Event-level deprecation
24
+ * const todoRenamed = Events.synced({
25
+ * name: 'v1.TodoRenamed',
26
+ * schema: Schema.Struct({ id: Schema.String, name: Schema.String }),
27
+ * deprecated: "Use 'v1.TodoUpdated' instead",
28
+ * })
29
+ * ```
30
+ * @module
31
+ */
32
+
33
+ import type { Schema } from '@livestore/utils/effect'
34
+ import { Effect, Option, SchemaAST } from '@livestore/utils/effect'
35
+
36
+ import type { EventDef } from './event-def.ts'
37
+
38
+ /** Symbol used to mark schemas as deprecated. */
39
+ export const DeprecatedId = Symbol.for('livestore/schema/annotations/deprecated')
40
+
41
+ /**
42
+ * Marks a schema field as deprecated with a reason message.
43
+ * When an event is committed with a deprecated field that has a value,
44
+ * a warning will be logged.
45
+ *
46
+ * Works with both Schema types and PropertySignatures (from Schema.optional).
47
+ *
48
+ * @param reason - Explanation of why this field is deprecated and what to use instead
49
+ * @returns A function that adds the deprecation annotation to the schema
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const schema = Schema.Struct({
54
+ * oldField: Schema.optional(Schema.String).pipe(deprecated("Use 'newField' instead")),
55
+ * newField: Schema.optional(Schema.String),
56
+ * })
57
+ * ```
58
+ */
59
+ export const deprecated =
60
+ (reason: string) =>
61
+ <T extends { annotations: (annotations: { readonly [DeprecatedId]?: string }) => T }>(schema: T): T =>
62
+ schema.annotations({ [DeprecatedId]: reason })
63
+
64
+ /**
65
+ * Checks if a schema has a deprecation annotation.
66
+ *
67
+ * @param schema - The schema to check
68
+ * @returns The deprecation reason if deprecated, None otherwise
69
+ */
70
+ export const getDeprecatedReason = <A, I, R>(schema: Schema.Schema<A, I, R>): Option.Option<string> =>
71
+ SchemaAST.getAnnotation<string>(DeprecatedId)(schema.ast)
72
+
73
+ /**
74
+ * Checks if a schema is deprecated.
75
+ *
76
+ * @param schema - The schema to check
77
+ * @returns true if the schema is deprecated
78
+ */
79
+ export const isDeprecated = <A, I, R>(schema: Schema.Schema<A, I, R>): boolean =>
80
+ Option.isSome(getDeprecatedReason(schema))
81
+
82
+ /**
83
+ * Finds deprecated fields with values in the given event arguments.
84
+ * This walks through a Struct schema and checks each property for deprecation.
85
+ *
86
+ * @param schema - The event schema (expected to be a Struct)
87
+ * @param args - The event arguments
88
+ * @returns Array of objects containing field name and deprecation reason
89
+ */
90
+ export const findDeprecatedFieldsWithValues = (
91
+ schema: Schema.Schema.All,
92
+ args: Record<string, unknown>,
93
+ ): Array<{ field: string; reason: string }> => {
94
+ const result: Array<{ field: string; reason: string }> = []
95
+ const ast = schema.ast
96
+
97
+ // Handle TypeLiteral (Struct) schemas
98
+ if (ast._tag === 'TypeLiteral') {
99
+ for (const prop of ast.propertySignatures) {
100
+ const fieldName = String(prop.name)
101
+ const fieldValue = args[fieldName]
102
+
103
+ // Only check fields that have a value (not undefined)
104
+ if (fieldValue !== undefined) {
105
+ // Check deprecation on the property signature itself (for Schema.optional(...).pipe(deprecated(...)))
106
+ const propAnnotations = prop.annotations as Record<symbol, unknown> | undefined
107
+ const deprecationReason = propAnnotations?.[DeprecatedId] as string | undefined
108
+
109
+ // Also check deprecation on the type (for direct field deprecation)
110
+ const typeDeprecation = SchemaAST.getAnnotation<string>(DeprecatedId)(prop.type)
111
+
112
+ const reason = deprecationReason ?? (Option.isSome(typeDeprecation) ? typeDeprecation.value : undefined)
113
+ if (reason !== undefined) {
114
+ result.push({ field: fieldName, reason })
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ return result
121
+ }
122
+
123
+ /** Set of event names that have already logged deprecation warnings. */
124
+ const warnedDeprecatedEvents = new Set<string>()
125
+
126
+ /** Map of event+field combinations that have already logged deprecation warnings. */
127
+ const warnedDeprecatedFields = new Set<string>()
128
+
129
+ /**
130
+ * Logs deprecation warnings for an event using Effect.logWarning.
131
+ * Checks both event-level and field-level deprecation, with deduplication.
132
+ *
133
+ * @param eventDef - The event definition to check
134
+ * @param args - The event arguments
135
+ * @returns An Effect that logs warnings for any deprecations found
136
+ */
137
+ export const logDeprecationWarnings = (
138
+ eventDef: EventDef.AnyWithoutFn,
139
+ args: Record<string, unknown>,
140
+ ): Effect.Effect<void> =>
141
+ Effect.gen(function* () {
142
+ const eventName = eventDef.name
143
+
144
+ // Check for event-level deprecation
145
+ const eventDeprecation = eventDef.options.deprecated
146
+ if (eventDeprecation !== undefined && !warnedDeprecatedEvents.has(eventName)) {
147
+ warnedDeprecatedEvents.add(eventName)
148
+ yield* Effect.logWarning('@livestore/schema:deprecated-event', {
149
+ event: eventName,
150
+ reason: eventDeprecation,
151
+ })
152
+ }
153
+
154
+ // Check for deprecated fields with values
155
+ const deprecatedFields = findDeprecatedFieldsWithValues(eventDef.schema, args)
156
+ for (const { field, reason } of deprecatedFields) {
157
+ const key = `${eventName}:${field}`
158
+ if (!warnedDeprecatedFields.has(key)) {
159
+ warnedDeprecatedFields.add(key)
160
+ yield* Effect.logWarning('@livestore/schema:deprecated-field', {
161
+ event: eventName,
162
+ field,
163
+ reason,
164
+ })
165
+ }
166
+ }
167
+ })
168
+
169
+ /**
170
+ * Resets the deprecation warning state. Useful for testing.
171
+ */
172
+ export const resetDeprecationWarnings = (): void => {
173
+ warnedDeprecatedEvents.clear()
174
+ warnedDeprecatedFields.clear()
175
+ }
@@ -51,6 +51,11 @@ export type EventDef<TName extends string, TType, TEncoded = TType, TDerived ext
51
51
 
52
52
  /** Whether this is a derived event. Derived events cannot have materializers. */
53
53
  derived: TDerived
54
+
55
+ /**
56
+ * Deprecation reason for this event. When set, a warning is logged at commit time.
57
+ */
58
+ deprecated: string | undefined
54
59
  }
55
60
 
56
61
  /**
@@ -1,4 +1,5 @@
1
1
  export * from './define.ts'
2
+ export * from './deprecated.ts'
2
3
  export * from './event-def.ts'
3
4
  export * from './facts.ts'
4
5
  export * from './materializer.ts'