@mepuka/skygent 0.2.0
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/README.md +59 -0
- package/index.ts +146 -0
- package/package.json +56 -0
- package/src/cli/app.ts +75 -0
- package/src/cli/config-command.ts +140 -0
- package/src/cli/config.ts +91 -0
- package/src/cli/derive.ts +205 -0
- package/src/cli/doc/annotation.ts +36 -0
- package/src/cli/doc/filter.ts +69 -0
- package/src/cli/doc/index.ts +9 -0
- package/src/cli/doc/post.ts +155 -0
- package/src/cli/doc/primitives.ts +25 -0
- package/src/cli/doc/render.ts +18 -0
- package/src/cli/doc/table.ts +114 -0
- package/src/cli/doc/thread.ts +46 -0
- package/src/cli/doc/tree.ts +126 -0
- package/src/cli/errors.ts +59 -0
- package/src/cli/exit-codes.ts +52 -0
- package/src/cli/feed.ts +177 -0
- package/src/cli/filter-dsl.ts +1411 -0
- package/src/cli/filter-errors.ts +208 -0
- package/src/cli/filter-help.ts +70 -0
- package/src/cli/filter-input.ts +54 -0
- package/src/cli/filter.ts +435 -0
- package/src/cli/graph.ts +472 -0
- package/src/cli/help.ts +14 -0
- package/src/cli/interval.ts +35 -0
- package/src/cli/jetstream.ts +173 -0
- package/src/cli/layers.ts +180 -0
- package/src/cli/logging.ts +136 -0
- package/src/cli/output-format.ts +26 -0
- package/src/cli/output.ts +82 -0
- package/src/cli/parse.ts +80 -0
- package/src/cli/post.ts +193 -0
- package/src/cli/preferences.ts +11 -0
- package/src/cli/query-fields.ts +247 -0
- package/src/cli/query.ts +415 -0
- package/src/cli/range.ts +44 -0
- package/src/cli/search.ts +465 -0
- package/src/cli/shared-options.ts +169 -0
- package/src/cli/shared.ts +20 -0
- package/src/cli/store-errors.ts +80 -0
- package/src/cli/store-tree.ts +392 -0
- package/src/cli/store.ts +395 -0
- package/src/cli/sync-factory.ts +107 -0
- package/src/cli/sync.ts +366 -0
- package/src/cli/view-thread.ts +196 -0
- package/src/cli/view.ts +47 -0
- package/src/cli/watch.ts +344 -0
- package/src/db/migrations/store-catalog/001_init.ts +14 -0
- package/src/db/migrations/store-index/001_init.ts +34 -0
- package/src/db/migrations/store-index/002_event_log.ts +24 -0
- package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
- package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
- package/src/db/migrations/store-index/005_post_lang.ts +15 -0
- package/src/db/migrations/store-index/006_has_embed.ts +10 -0
- package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
- package/src/domain/bsky.ts +467 -0
- package/src/domain/config.ts +11 -0
- package/src/domain/credentials.ts +6 -0
- package/src/domain/defaults.ts +8 -0
- package/src/domain/derivation.ts +55 -0
- package/src/domain/errors.ts +71 -0
- package/src/domain/events.ts +55 -0
- package/src/domain/extract.ts +64 -0
- package/src/domain/filter-describe.ts +551 -0
- package/src/domain/filter-explain.ts +9 -0
- package/src/domain/filter.ts +797 -0
- package/src/domain/format.ts +91 -0
- package/src/domain/index.ts +13 -0
- package/src/domain/indexes.ts +17 -0
- package/src/domain/policies.ts +16 -0
- package/src/domain/post.ts +88 -0
- package/src/domain/primitives.ts +50 -0
- package/src/domain/raw.ts +140 -0
- package/src/domain/store.ts +103 -0
- package/src/domain/sync.ts +211 -0
- package/src/domain/text-width.ts +56 -0
- package/src/services/app-config.ts +278 -0
- package/src/services/bsky-client.ts +2113 -0
- package/src/services/credential-store.ts +408 -0
- package/src/services/derivation-engine.ts +502 -0
- package/src/services/derivation-settings.ts +61 -0
- package/src/services/derivation-validator.ts +68 -0
- package/src/services/filter-compiler.ts +269 -0
- package/src/services/filter-library.ts +371 -0
- package/src/services/filter-runtime.ts +821 -0
- package/src/services/filter-settings.ts +30 -0
- package/src/services/identity-resolver.ts +563 -0
- package/src/services/jetstream-sync.ts +636 -0
- package/src/services/lineage-store.ts +89 -0
- package/src/services/link-validator.ts +244 -0
- package/src/services/output-manager.ts +274 -0
- package/src/services/post-parser.ts +62 -0
- package/src/services/profile-resolver.ts +223 -0
- package/src/services/resource-monitor.ts +106 -0
- package/src/services/shared.ts +69 -0
- package/src/services/store-cleaner.ts +43 -0
- package/src/services/store-commit.ts +168 -0
- package/src/services/store-db.ts +248 -0
- package/src/services/store-event-log.ts +285 -0
- package/src/services/store-index-sql.ts +289 -0
- package/src/services/store-index.ts +1152 -0
- package/src/services/store-keys.ts +4 -0
- package/src/services/store-manager.ts +358 -0
- package/src/services/store-stats.ts +522 -0
- package/src/services/store-writer.ts +200 -0
- package/src/services/sync-checkpoint-store.ts +169 -0
- package/src/services/sync-engine.ts +547 -0
- package/src/services/sync-reporter.ts +16 -0
- package/src/services/sync-settings.ts +72 -0
- package/src/services/trending-topics.ts +226 -0
- package/src/services/view-checkpoint-store.ts +238 -0
- package/src/typeclass/chunk.ts +84 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trending Topics Service
|
|
3
|
+
*
|
|
4
|
+
* Provides functionality to check if hashtags are trending on Bluesky.
|
|
5
|
+
* Uses the Bluesky API to fetch trending topics and caches results for 15 minutes
|
|
6
|
+
* to reduce API calls and improve performance.
|
|
7
|
+
*
|
|
8
|
+
* This service is primarily used by the Trending filter to determine if posts
|
|
9
|
+
* contain hashtags that are currently trending.
|
|
10
|
+
*
|
|
11
|
+
* @module services/trending-topics
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { Effect } from "effect";
|
|
15
|
+
* import { TrendingTopics } from "./services/trending-topics.js";
|
|
16
|
+
* import { Hashtag } from "./domain/primitives.js";
|
|
17
|
+
*
|
|
18
|
+
* const program = Effect.gen(function* () {
|
|
19
|
+
* const trending = yield* TrendingTopics;
|
|
20
|
+
*
|
|
21
|
+
* // Get all trending topics
|
|
22
|
+
* const topics = yield* trending.getTopics();
|
|
23
|
+
* console.log("Trending topics:", topics);
|
|
24
|
+
*
|
|
25
|
+
* // Check if a specific hashtag is trending
|
|
26
|
+
* const isTrending = yield* trending.isTrending(Hashtag.make("effect"));
|
|
27
|
+
* console.log("Is #effect trending?", isTrending);
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import * as KeyValueStore from "@effect/platform/KeyValueStore";
|
|
33
|
+
import {
|
|
34
|
+
Clock,
|
|
35
|
+
Context,
|
|
36
|
+
Duration,
|
|
37
|
+
Effect,
|
|
38
|
+
Layer,
|
|
39
|
+
Option,
|
|
40
|
+
Schema
|
|
41
|
+
} from "effect";
|
|
42
|
+
import { FilterEvalError } from "../domain/errors.js";
|
|
43
|
+
import { Hashtag } from "../domain/primitives.js";
|
|
44
|
+
import { BskyClient } from "./bsky-client.js";
|
|
45
|
+
|
|
46
|
+
/** Cache key prefix for trending topics storage */
|
|
47
|
+
const cachePrefix = "cache/trending/";
|
|
48
|
+
|
|
49
|
+
/** Cache key for storing trending topics */
|
|
50
|
+
const cacheKey = "topics";
|
|
51
|
+
|
|
52
|
+
/** Cache TTL - trending topics are cached for 15 minutes */
|
|
53
|
+
const cacheTtl = Duration.minutes(15);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Converts an error to a FilterEvalError with the given message.
|
|
57
|
+
* @param message - Error message describing the failure context
|
|
58
|
+
* @returns A function that takes a cause and creates a FilterEvalError
|
|
59
|
+
*/
|
|
60
|
+
const toFilterEvalError = (message: string) => (cause: unknown) =>
|
|
61
|
+
FilterEvalError.make({ message, cause });
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalizes a topic string for comparison by trimming whitespace,
|
|
65
|
+
* converting to lowercase, and removing the leading '#' if present.
|
|
66
|
+
* @param topic - The topic string to normalize
|
|
67
|
+
* @returns Normalized topic string
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* normalizeTopic("#Effect") // returns "effect"
|
|
71
|
+
* normalizeTopic(" BSKY ") // returns "bsky"
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
const normalizeTopic = (topic: string) =>
|
|
75
|
+
topic.trim().toLowerCase().replace(/^#/, "");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Schema for cached trending topics data.
|
|
79
|
+
* Stores the list of topics and when they were fetched.
|
|
80
|
+
*/
|
|
81
|
+
class TrendingCacheEntry extends Schema.Class<TrendingCacheEntry>("TrendingCacheEntry")({
|
|
82
|
+
/** Array of trending topic strings */
|
|
83
|
+
topics: Schema.Array(Schema.String),
|
|
84
|
+
/** Timestamp when the cache entry was created */
|
|
85
|
+
checkedAt: Schema.DateFromString
|
|
86
|
+
}) {}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Interface for the Trending Topics service.
|
|
90
|
+
* Provides methods to fetch and check trending topics on Bluesky.
|
|
91
|
+
*/
|
|
92
|
+
export type TrendingTopicsService = {
|
|
93
|
+
/**
|
|
94
|
+
* Fetches the current trending topics from Bluesky.
|
|
95
|
+
* Uses a 15-minute cache to avoid excessive API calls.
|
|
96
|
+
* @returns An Effect that resolves to an array of trending topic strings
|
|
97
|
+
* @throws {FilterEvalError} If fetching topics fails
|
|
98
|
+
*/
|
|
99
|
+
readonly getTopics: () => Effect.Effect<ReadonlyArray<string>, FilterEvalError>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Checks if a specific hashtag is currently trending.
|
|
103
|
+
* @param tag - The hashtag to check
|
|
104
|
+
* @returns An Effect that resolves to true if the hashtag is trending, false otherwise
|
|
105
|
+
* @throws {FilterEvalError} If checking trending status fails
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* const isTrending = yield* trending.isTrending(Hashtag.make("typescript"));
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
readonly isTrending: (tag: Hashtag) => Effect.Effect<boolean, FilterEvalError>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Effect Context Tag for the Trending Topics service.
|
|
116
|
+
* Provides trending topic functionality with caching for Bluesky hashtags.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* // Use in an Effect program
|
|
121
|
+
* const program = Effect.gen(function* () {
|
|
122
|
+
* const trending = yield* TrendingTopics;
|
|
123
|
+
* const topics = yield* trending.getTopics();
|
|
124
|
+
* });
|
|
125
|
+
*
|
|
126
|
+
* // Provide the layer
|
|
127
|
+
* const runnable = program.pipe(Effect.provide(TrendingTopics.layer));
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export class TrendingTopics extends Context.Tag("@skygent/TrendingTopics")<
|
|
131
|
+
TrendingTopics,
|
|
132
|
+
TrendingTopicsService
|
|
133
|
+
>() {
|
|
134
|
+
/**
|
|
135
|
+
* Production layer that provides the TrendingTopics service.
|
|
136
|
+
* Requires KeyValueStore and BskyClient to be provided.
|
|
137
|
+
*
|
|
138
|
+
* The implementation:
|
|
139
|
+
* - Fetches trending topics from Bluesky API
|
|
140
|
+
* - Caches results for 15 minutes
|
|
141
|
+
* - Normalizes topic names for case-insensitive comparison
|
|
142
|
+
*/
|
|
143
|
+
static readonly layer = Layer.effect(
|
|
144
|
+
TrendingTopics,
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const kv = yield* KeyValueStore.KeyValueStore;
|
|
147
|
+
const bsky = yield* BskyClient;
|
|
148
|
+
const store = KeyValueStore.prefix(
|
|
149
|
+
kv.forSchema(TrendingCacheEntry),
|
|
150
|
+
cachePrefix
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fetches trending topics from Bluesky API.
|
|
155
|
+
* Wrapped with error mapping for consistent error handling.
|
|
156
|
+
*/
|
|
157
|
+
const fetchTopics = bsky.getTrendingTopics().pipe(
|
|
158
|
+
Effect.mapError(toFilterEvalError("Trending topics fetch failed"))
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Checks if a cache entry is still fresh (within TTL).
|
|
163
|
+
* @param entry - The cached trending topics entry
|
|
164
|
+
* @param now - Current timestamp in milliseconds
|
|
165
|
+
* @returns True if the entry is still fresh
|
|
166
|
+
*/
|
|
167
|
+
const isFresh = (entry: TrendingCacheEntry, now: number) =>
|
|
168
|
+
now - entry.checkedAt.getTime() < Duration.toMillis(cacheTtl);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Gets trending topics, using cache if available and fresh.
|
|
172
|
+
* Fetches fresh data if cache is expired or missing.
|
|
173
|
+
*/
|
|
174
|
+
const getTopics = Effect.fn("TrendingTopics.getTopics")(() =>
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
const cached = yield* store
|
|
177
|
+
.get(cacheKey)
|
|
178
|
+
.pipe(Effect.mapError(toFilterEvalError("Trending cache read failed")));
|
|
179
|
+
|
|
180
|
+
const now = yield* Clock.currentTimeMillis;
|
|
181
|
+
if (Option.isSome(cached) && isFresh(cached.value, now)) {
|
|
182
|
+
return cached.value.topics;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const topics = yield* fetchTopics;
|
|
186
|
+
const entry = TrendingCacheEntry.make({
|
|
187
|
+
topics,
|
|
188
|
+
checkedAt: new Date(now)
|
|
189
|
+
});
|
|
190
|
+
yield* store
|
|
191
|
+
.set(cacheKey, entry)
|
|
192
|
+
.pipe(Effect.mapError(toFilterEvalError("Trending cache write failed")));
|
|
193
|
+
|
|
194
|
+
return topics;
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Checks if a specific hashtag is in the trending topics list.
|
|
200
|
+
* Uses normalized comparison for case-insensitive matching.
|
|
201
|
+
*/
|
|
202
|
+
const isTrending = Effect.fn("TrendingTopics.isTrending")((tag: Hashtag) =>
|
|
203
|
+
getTopics().pipe(
|
|
204
|
+
Effect.map((topics) =>
|
|
205
|
+
topics.includes(normalizeTopic(String(tag)))
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return TrendingTopics.of({ getTopics, isTrending });
|
|
211
|
+
})
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Test layer with mock trending topics for testing.
|
|
216
|
+
* Returns static data: ["effect", "bsky"]
|
|
217
|
+
*/
|
|
218
|
+
static readonly testLayer = Layer.succeed(
|
|
219
|
+
TrendingTopics,
|
|
220
|
+
TrendingTopics.of({
|
|
221
|
+
getTopics: () => Effect.succeed(["effect", "bsky"]),
|
|
222
|
+
isTrending: (tag) =>
|
|
223
|
+
Effect.succeed(["effect", "bsky"].includes(normalizeTopic(String(tag))))
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View Checkpoint Store Service
|
|
3
|
+
*
|
|
4
|
+
* Manages checkpoints for store derivation (view) operations. Tracks the last
|
|
5
|
+
* processed event from a source store to enable incremental derivation.
|
|
6
|
+
*
|
|
7
|
+
* Unlike sync checkpoints which track data sources, view checkpoints track
|
|
8
|
+
* the processing progress when deriving one store from another, allowing
|
|
9
|
+
* incremental updates rather than full re-derivation.
|
|
10
|
+
*
|
|
11
|
+
* @module services/view-checkpoint-store
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
15
|
+
import { DerivationCheckpoint, FilterEvaluationMode } from "../domain/derivation.js";
|
|
16
|
+
import { EventSeq, StoreName, StorePath, Timestamp } from "../domain/primitives.js";
|
|
17
|
+
import { StoreIoError } from "../domain/errors.js";
|
|
18
|
+
import { StoreDb } from "./store-db.js";
|
|
19
|
+
import { StoreManager } from "./store-manager.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Converts an error to a StoreIoError with context for the checkpoint path.
|
|
23
|
+
* @param viewName - The name of the derived store
|
|
24
|
+
* @param sourceName - The name of the source store
|
|
25
|
+
* @returns A function that creates StoreIoError from any cause
|
|
26
|
+
*/
|
|
27
|
+
const toStoreIoError = (viewName: StoreName, sourceName: StoreName) => (cause: unknown) => {
|
|
28
|
+
const path = Schema.decodeUnknownSync(StorePath)(
|
|
29
|
+
`stores/${viewName}/checkpoints/derivation/${sourceName}`
|
|
30
|
+
);
|
|
31
|
+
return StoreIoError.make({ path, cause });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const checkpointRow = Schema.Struct({
|
|
35
|
+
view_name: StoreName,
|
|
36
|
+
source_store: StoreName,
|
|
37
|
+
target_store: StoreName,
|
|
38
|
+
filter_hash: Schema.String,
|
|
39
|
+
evaluation_mode: FilterEvaluationMode,
|
|
40
|
+
last_source_event_seq: Schema.NullOr(EventSeq),
|
|
41
|
+
events_processed: Schema.NonNegativeInt,
|
|
42
|
+
events_matched: Schema.NonNegativeInt,
|
|
43
|
+
deletes_propagated: Schema.NonNegativeInt,
|
|
44
|
+
updated_at: Schema.String
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Service for managing derivation (view) checkpoints.
|
|
49
|
+
*
|
|
50
|
+
* This service tracks the processing progress when deriving one store from
|
|
51
|
+
* another, storing checkpoints that record which events from the source store
|
|
52
|
+
* have been processed. This enables efficient incremental derivation.
|
|
53
|
+
*/
|
|
54
|
+
export class ViewCheckpointStore extends Context.Tag("@skygent/ViewCheckpointStore")<
|
|
55
|
+
ViewCheckpointStore,
|
|
56
|
+
{
|
|
57
|
+
/**
|
|
58
|
+
* Loads the derivation checkpoint for a view and its source store.
|
|
59
|
+
*
|
|
60
|
+
* @param viewName - The name of the derived store
|
|
61
|
+
* @param sourceName - The name of the source store
|
|
62
|
+
* @returns Effect resolving to Option of DerivationCheckpoint, or StoreIoError on failure
|
|
63
|
+
*/
|
|
64
|
+
readonly load: (
|
|
65
|
+
viewName: StoreName,
|
|
66
|
+
sourceName: StoreName
|
|
67
|
+
) => Effect.Effect<Option.Option<DerivationCheckpoint>, StoreIoError>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Saves a derivation checkpoint.
|
|
71
|
+
*
|
|
72
|
+
* @param checkpoint - The checkpoint data to persist
|
|
73
|
+
* @returns Effect resolving to void, or StoreIoError on failure
|
|
74
|
+
*/
|
|
75
|
+
readonly save: (
|
|
76
|
+
checkpoint: DerivationCheckpoint
|
|
77
|
+
) => Effect.Effect<void, StoreIoError>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Removes a derivation checkpoint.
|
|
81
|
+
*
|
|
82
|
+
* @param viewName - The name of the derived store
|
|
83
|
+
* @param sourceName - The name of the source store
|
|
84
|
+
* @returns Effect resolving to void, or StoreIoError on failure
|
|
85
|
+
*/
|
|
86
|
+
readonly remove: (
|
|
87
|
+
viewName: StoreName,
|
|
88
|
+
sourceName: StoreName
|
|
89
|
+
) => Effect.Effect<void, StoreIoError>;
|
|
90
|
+
}
|
|
91
|
+
>() {
|
|
92
|
+
static readonly layer = Layer.effect(
|
|
93
|
+
ViewCheckpointStore,
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const storeDb = yield* StoreDb;
|
|
96
|
+
const manager = yield* StoreManager;
|
|
97
|
+
|
|
98
|
+
const resolveStore = (viewName: StoreName, sourceName: StoreName) =>
|
|
99
|
+
manager
|
|
100
|
+
.getStore(viewName)
|
|
101
|
+
.pipe(Effect.mapError(toStoreIoError(viewName, sourceName)));
|
|
102
|
+
|
|
103
|
+
const load = Effect.fn("ViewCheckpointStore.load")(
|
|
104
|
+
(viewName: StoreName, sourceName: StoreName) =>
|
|
105
|
+
resolveStore(viewName, sourceName).pipe(
|
|
106
|
+
Effect.flatMap(
|
|
107
|
+
Option.match({
|
|
108
|
+
onNone: () => Effect.succeed(Option.none<DerivationCheckpoint>()),
|
|
109
|
+
onSome: (storeRef) =>
|
|
110
|
+
storeDb.withClient(storeRef, (client) =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const rows = yield* client`SELECT
|
|
113
|
+
view_name,
|
|
114
|
+
source_store,
|
|
115
|
+
target_store,
|
|
116
|
+
filter_hash,
|
|
117
|
+
evaluation_mode,
|
|
118
|
+
last_source_event_seq,
|
|
119
|
+
events_processed,
|
|
120
|
+
events_matched,
|
|
121
|
+
deletes_propagated,
|
|
122
|
+
updated_at
|
|
123
|
+
FROM derivation_checkpoints
|
|
124
|
+
WHERE view_name = ${viewName} AND source_store = ${sourceName}`;
|
|
125
|
+
if (rows.length === 0) {
|
|
126
|
+
return Option.none<DerivationCheckpoint>();
|
|
127
|
+
}
|
|
128
|
+
const decoded = yield* Schema.decodeUnknown(
|
|
129
|
+
Schema.Array(checkpointRow)
|
|
130
|
+
)(rows);
|
|
131
|
+
const row = decoded[0]!;
|
|
132
|
+
const updatedAt = yield* Schema.decodeUnknown(Timestamp)(row.updated_at);
|
|
133
|
+
return Option.some(
|
|
134
|
+
DerivationCheckpoint.make({
|
|
135
|
+
viewName: row.view_name,
|
|
136
|
+
sourceStore: row.source_store,
|
|
137
|
+
targetStore: row.target_store,
|
|
138
|
+
filterHash: row.filter_hash,
|
|
139
|
+
evaluationMode: row.evaluation_mode,
|
|
140
|
+
lastSourceEventSeq: row.last_source_event_seq ?? undefined,
|
|
141
|
+
eventsProcessed: row.events_processed,
|
|
142
|
+
eventsMatched: row.events_matched,
|
|
143
|
+
deletesPropagated: row.deletes_propagated,
|
|
144
|
+
updatedAt
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
})
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
),
|
|
151
|
+
Effect.mapError(toStoreIoError(viewName, sourceName))
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const save = Effect.fn("ViewCheckpointStore.save")(
|
|
156
|
+
(checkpoint: DerivationCheckpoint) =>
|
|
157
|
+
resolveStore(checkpoint.viewName, checkpoint.sourceStore).pipe(
|
|
158
|
+
Effect.flatMap(
|
|
159
|
+
Option.match({
|
|
160
|
+
onNone: () =>
|
|
161
|
+
Effect.fail(
|
|
162
|
+
toStoreIoError(checkpoint.viewName, checkpoint.sourceStore)(
|
|
163
|
+
new Error(`Store "${checkpoint.viewName}" not found`)
|
|
164
|
+
)
|
|
165
|
+
),
|
|
166
|
+
onSome: (storeRef) =>
|
|
167
|
+
storeDb.withClient(storeRef, (client) =>
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const updatedAt = checkpoint.updatedAt.toISOString();
|
|
170
|
+
yield* client`INSERT INTO derivation_checkpoints (
|
|
171
|
+
view_name,
|
|
172
|
+
source_store,
|
|
173
|
+
target_store,
|
|
174
|
+
filter_hash,
|
|
175
|
+
evaluation_mode,
|
|
176
|
+
last_source_event_seq,
|
|
177
|
+
events_processed,
|
|
178
|
+
events_matched,
|
|
179
|
+
deletes_propagated,
|
|
180
|
+
updated_at
|
|
181
|
+
)
|
|
182
|
+
VALUES (
|
|
183
|
+
${checkpoint.viewName},
|
|
184
|
+
${checkpoint.sourceStore},
|
|
185
|
+
${checkpoint.targetStore},
|
|
186
|
+
${checkpoint.filterHash},
|
|
187
|
+
${checkpoint.evaluationMode},
|
|
188
|
+
${checkpoint.lastSourceEventSeq ?? null},
|
|
189
|
+
${checkpoint.eventsProcessed},
|
|
190
|
+
${checkpoint.eventsMatched},
|
|
191
|
+
${checkpoint.deletesPropagated},
|
|
192
|
+
${updatedAt}
|
|
193
|
+
)
|
|
194
|
+
ON CONFLICT(view_name, source_store) DO UPDATE SET
|
|
195
|
+
target_store = excluded.target_store,
|
|
196
|
+
filter_hash = excluded.filter_hash,
|
|
197
|
+
evaluation_mode = excluded.evaluation_mode,
|
|
198
|
+
last_source_event_seq = CASE
|
|
199
|
+
WHEN excluded.last_source_event_seq IS NULL THEN derivation_checkpoints.last_source_event_seq
|
|
200
|
+
WHEN derivation_checkpoints.last_source_event_seq IS NULL THEN excluded.last_source_event_seq
|
|
201
|
+
WHEN excluded.last_source_event_seq >= derivation_checkpoints.last_source_event_seq THEN excluded.last_source_event_seq
|
|
202
|
+
ELSE derivation_checkpoints.last_source_event_seq
|
|
203
|
+
END,
|
|
204
|
+
events_processed = excluded.events_processed,
|
|
205
|
+
events_matched = excluded.events_matched,
|
|
206
|
+
deletes_propagated = excluded.deletes_propagated,
|
|
207
|
+
updated_at = excluded.updated_at`;
|
|
208
|
+
})
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
),
|
|
212
|
+
Effect.mapError(toStoreIoError(checkpoint.viewName, checkpoint.sourceStore))
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const remove = Effect.fn("ViewCheckpointStore.remove")(
|
|
217
|
+
(viewName: StoreName, sourceName: StoreName) =>
|
|
218
|
+
resolveStore(viewName, sourceName).pipe(
|
|
219
|
+
Effect.flatMap(
|
|
220
|
+
Option.match({
|
|
221
|
+
onNone: () => Effect.void,
|
|
222
|
+
onSome: (storeRef) =>
|
|
223
|
+
storeDb.withClient(storeRef, (client) =>
|
|
224
|
+
client`DELETE FROM derivation_checkpoints
|
|
225
|
+
WHERE view_name = ${viewName} AND source_store = ${sourceName}`.pipe(
|
|
226
|
+
Effect.asVoid
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
),
|
|
231
|
+
Effect.mapError(toStoreIoError(viewName, sourceName))
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return ViewCheckpointStore.of({ load, save, remove });
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Applicative } from "@effect/typeclass/Applicative";
|
|
2
|
+
import { getApplicative, type ConcurrencyOptions } from "@effect/typeclass/data/Effect";
|
|
3
|
+
import type * as Filterable from "@effect/typeclass/Filterable";
|
|
4
|
+
import type * as Traversable from "@effect/typeclass/Traversable";
|
|
5
|
+
import type * as TraversableFilterable from "@effect/typeclass/TraversableFilterable";
|
|
6
|
+
import { Chunk, Effect, Option } from "effect";
|
|
7
|
+
import type { ChunkTypeLambda } from "effect/Chunk";
|
|
8
|
+
import { dual } from "effect/Function";
|
|
9
|
+
import type { Kind, TypeLambda } from "effect/HKT";
|
|
10
|
+
import type { Either } from "effect/Either";
|
|
11
|
+
|
|
12
|
+
export const ChunkFilterable: Filterable.Filterable<ChunkTypeLambda> = {
|
|
13
|
+
partitionMap: Chunk.partitionMap,
|
|
14
|
+
filterMap: Chunk.filterMap
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const traverse = <F extends TypeLambda>(F: Applicative<F>) =>
|
|
18
|
+
dual(
|
|
19
|
+
2,
|
|
20
|
+
<A, R, O, E, B>(
|
|
21
|
+
self: Chunk.Chunk<A>,
|
|
22
|
+
f: (a: A) => Kind<F, R, O, E, B>
|
|
23
|
+
): Kind<F, R, O, E, Chunk.Chunk<B>> =>
|
|
24
|
+
F.map(
|
|
25
|
+
F.productAll(Chunk.toReadonlyArray(self).map(f)),
|
|
26
|
+
Chunk.fromIterable
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const ChunkTraversable: Traversable.Traversable<ChunkTypeLambda> = {
|
|
31
|
+
traverse
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const traversePartitionMap = <F extends TypeLambda>(F: Applicative<F>) =>
|
|
35
|
+
dual(
|
|
36
|
+
2,
|
|
37
|
+
<A, R, O, E, B, C>(
|
|
38
|
+
self: Chunk.Chunk<A>,
|
|
39
|
+
f: (a: A) => Kind<F, R, O, E, Either<C, B>>
|
|
40
|
+
): Kind<F, R, O, E, [Chunk.Chunk<B>, Chunk.Chunk<C>]> =>
|
|
41
|
+
F.map(
|
|
42
|
+
traverse(F)(self, f) as Kind<F, R, O, E, Chunk.Chunk<Either<C, B>>>,
|
|
43
|
+
(chunk) => Chunk.separate(chunk)
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const traverseFilterMap = <F extends TypeLambda>(F: Applicative<F>) =>
|
|
48
|
+
dual(
|
|
49
|
+
2,
|
|
50
|
+
<A, R, O, E, B>(
|
|
51
|
+
self: Chunk.Chunk<A>,
|
|
52
|
+
f: (a: A) => Kind<F, R, O, E, Option.Option<B>>
|
|
53
|
+
): Kind<F, R, O, E, Chunk.Chunk<B>> =>
|
|
54
|
+
F.map(
|
|
55
|
+
traverse(F)(self, f) as Kind<F, R, O, E, Chunk.Chunk<Option.Option<B>>>,
|
|
56
|
+
(chunk) => Chunk.compact(chunk)
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
export const ChunkTraversableFilterable: TraversableFilterable.TraversableFilterable<ChunkTypeLambda> = {
|
|
61
|
+
traversePartitionMap,
|
|
62
|
+
traverseFilterMap
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const traverseFilterEffect = <A, E, R>(
|
|
66
|
+
items: Chunk.Chunk<A>,
|
|
67
|
+
predicate: (item: A) => Effect.Effect<boolean, E, R>,
|
|
68
|
+
options?: ConcurrencyOptions
|
|
69
|
+
): Effect.Effect<Chunk.Chunk<A>, E, R> =>
|
|
70
|
+
ChunkTraversableFilterable.traverseFilterMap(getApplicative(options))(
|
|
71
|
+
items,
|
|
72
|
+
(item) =>
|
|
73
|
+
Effect.map(predicate(item), (keep) =>
|
|
74
|
+
keep ? Option.some(item) : Option.none()
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export const filterByFlags = <A>(
|
|
79
|
+
items: Chunk.Chunk<A>,
|
|
80
|
+
flags: Chunk.Chunk<boolean>
|
|
81
|
+
): Chunk.Chunk<A> =>
|
|
82
|
+
ChunkFilterable.filterMap(Chunk.zip(items, flags), (tuple) =>
|
|
83
|
+
tuple[1] ? Option.some(tuple[0]) : Option.none()
|
|
84
|
+
);
|