@livestore/common 0.4.0-dev.21 → 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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/ClientSessionLeaderThreadProxy.d.ts +7 -0
- package/dist/ClientSessionLeaderThreadProxy.d.ts.map +1 -1
- package/dist/ClientSessionLeaderThreadProxy.js.map +1 -1
- package/dist/adapter-types.d.ts +23 -0
- package/dist/adapter-types.d.ts.map +1 -1
- package/dist/adapter-types.js +27 -1
- package/dist/adapter-types.js.map +1 -1
- package/dist/devtools/devtools-messages-client-session.d.ts +42 -22
- package/dist/devtools/devtools-messages-client-session.d.ts.map +1 -1
- package/dist/devtools/devtools-messages-client-session.js +12 -1
- package/dist/devtools/devtools-messages-client-session.js.map +1 -1
- package/dist/devtools/devtools-messages-common.d.ts +12 -6
- package/dist/devtools/devtools-messages-common.d.ts.map +1 -1
- package/dist/devtools/devtools-messages-common.js +7 -2
- package/dist/devtools/devtools-messages-common.js.map +1 -1
- package/dist/devtools/devtools-messages-leader.d.ts +45 -25
- package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
- package/dist/devtools/devtools-messages-leader.js +12 -1
- package/dist/devtools/devtools-messages-leader.js.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.js +10 -10
- package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
- package/dist/leader-thread/leader-worker-devtools.js +9 -0
- package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.d.ts +4 -2
- package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.js +5 -1
- package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
- package/dist/leader-thread/materialize-event.d.ts.map +1 -1
- package/dist/leader-thread/materialize-event.js +3 -0
- package/dist/leader-thread/materialize-event.js.map +1 -1
- package/dist/schema/EventDef/define.d.ts +14 -0
- package/dist/schema/EventDef/define.d.ts.map +1 -1
- package/dist/schema/EventDef/define.js +1 -0
- package/dist/schema/EventDef/define.js.map +1 -1
- package/dist/schema/EventDef/deprecated.d.ts +99 -0
- package/dist/schema/EventDef/deprecated.d.ts.map +1 -0
- package/dist/schema/EventDef/deprecated.js +144 -0
- package/dist/schema/EventDef/deprecated.js.map +1 -0
- package/dist/schema/EventDef/deprecated.test.d.ts +2 -0
- package/dist/schema/EventDef/deprecated.test.d.ts.map +1 -0
- package/dist/schema/EventDef/deprecated.test.js +95 -0
- package/dist/schema/EventDef/deprecated.test.js.map +1 -0
- package/dist/schema/EventDef/event-def.d.ts +4 -0
- package/dist/schema/EventDef/event-def.d.ts.map +1 -1
- package/dist/schema/EventDef/mod.d.ts +1 -0
- package/dist/schema/EventDef/mod.d.ts.map +1 -1
- package/dist/schema/EventDef/mod.js +1 -0
- package/dist/schema/EventDef/mod.js.map +1 -1
- package/dist/schema/LiveStoreEvent/client.d.ts +6 -6
- package/dist/schema/state/sqlite/client-document-def.d.ts +1 -0
- package/dist/schema/state/sqlite/client-document-def.d.ts.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.js +17 -8
- package/dist/schema/state/sqlite/client-document-def.js.map +1 -1
- package/dist/schema/state/sqlite/client-document-def.test.js +120 -1
- package/dist/schema/state/sqlite/client-document-def.test.js.map +1 -1
- package/dist/schema/state/sqlite/query-builder/api.d.ts +27 -11
- package/dist/schema/state/sqlite/query-builder/api.d.ts.map +1 -1
- package/dist/schema/state/sqlite/query-builder/astToSql.d.ts.map +1 -1
- package/dist/schema/state/sqlite/query-builder/astToSql.js +71 -1
- package/dist/schema/state/sqlite/query-builder/astToSql.js.map +1 -1
- package/dist/schema/state/sqlite/query-builder/impl.test.js +109 -1
- package/dist/schema/state/sqlite/query-builder/impl.test.js.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.js +6 -2
- package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
- package/dist/version.d.ts +7 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +8 -1
- package/dist/version.js.map +1 -1
- package/package.json +4 -4
- package/src/ClientSessionLeaderThreadProxy.ts +7 -0
- package/src/adapter-types.ts +30 -0
- package/src/devtools/devtools-messages-client-session.ts +12 -0
- package/src/devtools/devtools-messages-common.ts +7 -3
- package/src/devtools/devtools-messages-leader.ts +12 -0
- package/src/leader-thread/LeaderSyncProcessor.ts +81 -40
- package/src/leader-thread/leader-worker-devtools.ts +11 -0
- package/src/leader-thread/make-leader-thread-layer.ts +8 -0
- package/src/leader-thread/materialize-event.ts +4 -0
- package/src/schema/EventDef/define.ts +16 -0
- package/src/schema/EventDef/deprecated.test.ts +128 -0
- package/src/schema/EventDef/deprecated.ts +175 -0
- package/src/schema/EventDef/event-def.ts +5 -0
- package/src/schema/EventDef/mod.ts +1 -0
- package/src/schema/state/sqlite/client-document-def.test.ts +140 -2
- package/src/schema/state/sqlite/client-document-def.ts +18 -9
- package/src/schema/state/sqlite/query-builder/api.ts +25 -3
- package/src/schema/state/sqlite/query-builder/astToSql.ts +81 -1
- package/src/schema/state/sqlite/query-builder/impl.test.ts +141 -1
- package/src/sync/ClientSessionSyncProcessor.ts +26 -13
- package/src/version.ts +9 -1
|
@@ -37,6 +37,11 @@ import { rollback } from './materialize-event.ts'
|
|
|
37
37
|
import type { InitialBlockingSyncContext, LeaderSyncProcessor } from './types.ts'
|
|
38
38
|
import { LeaderThreadCtx } from './types.ts'
|
|
39
39
|
|
|
40
|
+
// WORKAROUND: @effect/opentelemetry mis-parses `Span.addEvent(name, attributes)` and treats the attributes object as a
|
|
41
|
+
// time input, causing `TypeError: {} is not iterable` at runtime.
|
|
42
|
+
// Upstream: https://github.com/Effect-TS/effect/pull/5929
|
|
43
|
+
// TODO: simplify back to the 2-arg overload once the upstream fix is released and adopted.
|
|
44
|
+
|
|
40
45
|
type LocalPushQueueItem = [
|
|
41
46
|
event: LiveStoreEvent.Client.EncodedWithMeta,
|
|
42
47
|
deferred: Deferred.Deferred<void, LeaderAheadError> | undefined,
|
|
@@ -476,10 +481,14 @@ const backgroundApplyLocalPushes = ({
|
|
|
476
481
|
)
|
|
477
482
|
|
|
478
483
|
if (droppedItems.length > 0) {
|
|
479
|
-
otelSpan?.addEvent(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
484
|
+
otelSpan?.addEvent(
|
|
485
|
+
`push:drop-old-generation`,
|
|
486
|
+
{
|
|
487
|
+
droppedCount: droppedItems.length,
|
|
488
|
+
currentRebaseGeneration,
|
|
489
|
+
},
|
|
490
|
+
undefined,
|
|
491
|
+
)
|
|
483
492
|
|
|
484
493
|
/**
|
|
485
494
|
* Dropped pushes may still have a deferred awaiting completion.
|
|
@@ -517,20 +526,28 @@ const backgroundApplyLocalPushes = ({
|
|
|
517
526
|
|
|
518
527
|
switch (mergeResult._tag) {
|
|
519
528
|
case 'unknown-error': {
|
|
520
|
-
otelSpan?.addEvent(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
529
|
+
otelSpan?.addEvent(
|
|
530
|
+
`push:unknown-error`,
|
|
531
|
+
{
|
|
532
|
+
batchSize: newEvents.length,
|
|
533
|
+
newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
|
|
534
|
+
},
|
|
535
|
+
undefined,
|
|
536
|
+
)
|
|
524
537
|
return yield* new UnknownError({ cause: mergeResult.message })
|
|
525
538
|
}
|
|
526
539
|
case 'rebase': {
|
|
527
540
|
return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
|
|
528
541
|
}
|
|
529
542
|
case 'reject': {
|
|
530
|
-
otelSpan?.addEvent(
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
543
|
+
otelSpan?.addEvent(
|
|
544
|
+
`push:reject`,
|
|
545
|
+
{
|
|
546
|
+
batchSize: newEvents.length,
|
|
547
|
+
mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
|
|
548
|
+
},
|
|
549
|
+
undefined,
|
|
550
|
+
)
|
|
534
551
|
|
|
535
552
|
// TODO: how to test this?
|
|
536
553
|
const nextRebaseGeneration = currentRebaseGeneration + 1
|
|
@@ -585,10 +602,14 @@ const backgroundApplyLocalPushes = ({
|
|
|
585
602
|
leaderHead: mergeResult.newSyncState.localHead,
|
|
586
603
|
})
|
|
587
604
|
|
|
588
|
-
otelSpan?.addEvent(
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
605
|
+
otelSpan?.addEvent(
|
|
606
|
+
`push:advance`,
|
|
607
|
+
{
|
|
608
|
+
batchSize: newEvents.length,
|
|
609
|
+
mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
|
|
610
|
+
},
|
|
611
|
+
undefined,
|
|
612
|
+
)
|
|
592
613
|
|
|
593
614
|
// Don't sync client-local events
|
|
594
615
|
const filteredBatch = mergeResult.newEvents.filter((eventEncoded) => {
|
|
@@ -719,10 +740,14 @@ const backgroundBackendPulling = ({
|
|
|
719
740
|
if (mergeResult._tag === 'reject') {
|
|
720
741
|
return shouldNeverHappen('The leader thread should never reject upstream advances')
|
|
721
742
|
} else if (mergeResult._tag === 'unknown-error') {
|
|
722
|
-
otelSpan?.addEvent(
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
743
|
+
otelSpan?.addEvent(
|
|
744
|
+
`pull:unknown-error`,
|
|
745
|
+
{
|
|
746
|
+
newEventsCount: newEvents.length,
|
|
747
|
+
newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
|
|
748
|
+
},
|
|
749
|
+
undefined,
|
|
750
|
+
)
|
|
726
751
|
return yield* new UnknownError({ cause: mergeResult.message })
|
|
727
752
|
}
|
|
728
753
|
|
|
@@ -731,12 +756,16 @@ const backgroundBackendPulling = ({
|
|
|
731
756
|
Eventlog.updateBackendHead(dbEventlog, newBackendHead)
|
|
732
757
|
|
|
733
758
|
if (mergeResult._tag === 'rebase') {
|
|
734
|
-
otelSpan?.addEvent(
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
759
|
+
otelSpan?.addEvent(
|
|
760
|
+
`pull:rebase[${mergeResult.newSyncState.localHead.rebaseGeneration}]`,
|
|
761
|
+
{
|
|
762
|
+
newEventsCount: newEvents.length,
|
|
763
|
+
newEvents: TRACE_VERBOSE ? JSON.stringify(newEvents) : undefined,
|
|
764
|
+
rollbackCount: mergeResult.rollbackEvents.length,
|
|
765
|
+
mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
|
|
766
|
+
},
|
|
767
|
+
undefined,
|
|
768
|
+
)
|
|
740
769
|
|
|
741
770
|
const globalRebasedPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
|
|
742
771
|
const eventDef = schema.eventsDefsMap.get(event.name)
|
|
@@ -757,10 +786,14 @@ const backgroundBackendPulling = ({
|
|
|
757
786
|
leaderHead: mergeResult.newSyncState.localHead,
|
|
758
787
|
})
|
|
759
788
|
} else {
|
|
760
|
-
otelSpan?.addEvent(
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
789
|
+
otelSpan?.addEvent(
|
|
790
|
+
`pull:advance`,
|
|
791
|
+
{
|
|
792
|
+
newEventsCount: newEvents.length,
|
|
793
|
+
mergeResult: TRACE_VERBOSE ? JSON.stringify(mergeResult) : undefined,
|
|
794
|
+
},
|
|
795
|
+
undefined,
|
|
796
|
+
)
|
|
764
797
|
|
|
765
798
|
// Ensure push fiber is active after advance by restarting with current pending (non-client) events
|
|
766
799
|
const globalPendingEvents = mergeResult.newSyncState.pending.filter((event) => {
|
|
@@ -870,10 +903,14 @@ const backgroundBackendPushing = ({
|
|
|
870
903
|
yield* devtoolsLatch.await
|
|
871
904
|
}
|
|
872
905
|
|
|
873
|
-
otelSpan?.addEvent(
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
906
|
+
otelSpan?.addEvent(
|
|
907
|
+
'backend-push',
|
|
908
|
+
{
|
|
909
|
+
batchSize: queueItems.length,
|
|
910
|
+
batch: TRACE_VERBOSE ? JSON.stringify(queueItems) : undefined,
|
|
911
|
+
},
|
|
912
|
+
undefined,
|
|
913
|
+
)
|
|
877
914
|
|
|
878
915
|
// Push with declarative retry/backoff using Effect schedules
|
|
879
916
|
// - Exponential backoff starting at 1s and doubling (1s, 2s, 4s, 8s, 16s, 30s ...)
|
|
@@ -900,15 +937,19 @@ const backgroundBackendPushing = ({
|
|
|
900
937
|
|
|
901
938
|
const retries = iteration.recurrence
|
|
902
939
|
if (retries > 0 && pushResult._tag === 'Right') {
|
|
903
|
-
otelSpan?.addEvent('backend-push-retry-success', { retries, batchSize: queueItems.length })
|
|
940
|
+
otelSpan?.addEvent('backend-push-retry-success', { retries, batchSize: queueItems.length }, undefined)
|
|
904
941
|
}
|
|
905
942
|
|
|
906
943
|
if (pushResult._tag === 'Left') {
|
|
907
|
-
otelSpan?.addEvent(
|
|
908
|
-
error
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
944
|
+
otelSpan?.addEvent(
|
|
945
|
+
'backend-push-error',
|
|
946
|
+
{
|
|
947
|
+
error: pushResult.left.toString(),
|
|
948
|
+
retries,
|
|
949
|
+
batchSize: queueItems.length,
|
|
950
|
+
},
|
|
951
|
+
undefined,
|
|
952
|
+
)
|
|
912
953
|
const error = pushResult.left
|
|
913
954
|
if (
|
|
914
955
|
error._tag === 'IsOfflineError' ||
|
|
@@ -138,6 +138,17 @@ const listenToDevtools = ({
|
|
|
138
138
|
|
|
139
139
|
switch (decodedEvent._tag) {
|
|
140
140
|
case 'LSD.Leader.Ping': {
|
|
141
|
+
// Check version mismatch and respond with VersionMismatch if versions don't match
|
|
142
|
+
if (decodedEvent.liveStoreVersion !== liveStoreVersion) {
|
|
143
|
+
yield* sendMessage(
|
|
144
|
+
Devtools.Leader.VersionMismatch.make({
|
|
145
|
+
...reqPayload,
|
|
146
|
+
appVersion: liveStoreVersion,
|
|
147
|
+
receivedVersion: decodedEvent.liveStoreVersion,
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
141
152
|
yield* sendMessage(Devtools.Leader.Pong.make({ ...reqPayload }))
|
|
142
153
|
return
|
|
143
154
|
}
|
|
@@ -55,6 +55,8 @@ export interface MakeLeaderThreadLayerParams {
|
|
|
55
55
|
dbEventlog: LeaderSqliteDb
|
|
56
56
|
devtoolsOptions: DevtoolsOptions
|
|
57
57
|
shutdownChannel: ShutdownChannel
|
|
58
|
+
/** Boot warning to emit (e.g., OPFS unavailable in private browsing) */
|
|
59
|
+
bootWarning?: BootStatus
|
|
58
60
|
params?: {
|
|
59
61
|
localPushBatchSize?: number
|
|
60
62
|
backendPushBatchSize?: number
|
|
@@ -80,6 +82,7 @@ export const makeLeaderThreadLayer = ({
|
|
|
80
82
|
dbEventlog,
|
|
81
83
|
devtoolsOptions,
|
|
82
84
|
shutdownChannel,
|
|
85
|
+
bootWarning,
|
|
83
86
|
params,
|
|
84
87
|
testing,
|
|
85
88
|
}: MakeLeaderThreadLayerParams): Layer.Layer<LeaderThreadCtx, UnknownError, Scope.Scope | HttpClient.HttpClient> =>
|
|
@@ -89,6 +92,11 @@ export const makeLeaderThreadLayer = ({
|
|
|
89
92
|
|
|
90
93
|
const bootStatusQueue = yield* Queue.unbounded<BootStatus>().pipe(Effect.acquireRelease(Queue.shutdown))
|
|
91
94
|
|
|
95
|
+
// Emit boot warning if present (e.g., OPFS unavailable in private browsing)
|
|
96
|
+
if (bootWarning !== undefined) {
|
|
97
|
+
yield* Queue.offer(bootStatusQueue, bootWarning)
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
const dbEventlogMissing = !hasEventlogTables(dbEventlog)
|
|
93
101
|
|
|
94
102
|
// Either happens on initial boot or if schema changes
|
|
@@ -3,6 +3,7 @@ import { Effect, Option, ReadonlyArray, Schema } from '@livestore/utils/effect'
|
|
|
3
3
|
|
|
4
4
|
import { MaterializeError, MaterializerHashMismatchError, type SqliteDb } from '../adapter-types.ts'
|
|
5
5
|
import { getExecStatementsFromMaterializer, hashMaterializerResults } from '../materializer-helper.ts'
|
|
6
|
+
import { logDeprecationWarnings } from '../schema/EventDef/deprecated.ts'
|
|
6
7
|
import type { LiveStoreSchema } from '../schema/mod.ts'
|
|
7
8
|
import { EventSequenceNumber, resolveEventDef, SystemTables, UNKNOWN_EVENT_SCHEMA_HASH } from '../schema/mod.ts'
|
|
8
9
|
import { insertRow } from '../sql-queries/index.ts'
|
|
@@ -62,6 +63,9 @@ export const makeMaterializeEvent = ({
|
|
|
62
63
|
|
|
63
64
|
const { eventDef, materializer } = resolution
|
|
64
65
|
|
|
66
|
+
// Log deprecation warnings for deprecated events/fields
|
|
67
|
+
yield* logDeprecationWarnings(eventDef, eventEncoded.args as Record<string, unknown>)
|
|
68
|
+
|
|
65
69
|
const execArgsArr = getExecStatementsFromMaterializer({
|
|
66
70
|
eventDef,
|
|
67
71
|
materializer,
|
|
@@ -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
|
/**
|