@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,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter runtime service for compiling and evaluating filter expressions against posts.
|
|
3
|
+
*
|
|
4
|
+
* This service provides the core filtering logic for Skygent. It compiles filter
|
|
5
|
+
* expressions into executable predicates and supports both synchronous and effectful
|
|
6
|
+
* filters. Effectful filters (like `HasValidLinks` and `Trending`) can perform
|
|
7
|
+
* external operations such as HTTP requests.
|
|
8
|
+
*
|
|
9
|
+
* ## Features
|
|
10
|
+
*
|
|
11
|
+
* - **Filter compilation**: Converts FilterExpr AST into executable predicates
|
|
12
|
+
* - **Effectful filters**: Supports filters requiring async operations with retry policies
|
|
13
|
+
* - **Batch evaluation**: Efficiently evaluates filters against multiple posts
|
|
14
|
+
* - **Explanation mode**: Provides detailed reasoning for filter decisions
|
|
15
|
+
* - **Error policies**: Configurable handling of filter evaluation errors (Include/Exclude/Retry)
|
|
16
|
+
*
|
|
17
|
+
* ## Filter Types
|
|
18
|
+
*
|
|
19
|
+
* ### Simple Filters
|
|
20
|
+
* - `All`, `None`: Identity filters
|
|
21
|
+
* - `Author`, `AuthorIn`: Match by author handle
|
|
22
|
+
* - `Hashtag`, `HashtagIn`: Match by hashtag
|
|
23
|
+
* - `Contains`: Text substring matching
|
|
24
|
+
* - `IsReply`, `IsQuote`, `IsRepost`, `IsOriginal`: Post type matching
|
|
25
|
+
* - `HasImages`, `HasVideo`, `HasLinks`, `HasMedia`, `HasEmbed`: Media detection
|
|
26
|
+
* - `Engagement`: Threshold-based engagement matching
|
|
27
|
+
* - `Language`: Language code matching
|
|
28
|
+
* - `Regex`: Regular expression pattern matching
|
|
29
|
+
* - `DateRange`: Creation date range matching
|
|
30
|
+
*
|
|
31
|
+
* ### Effectful Filters
|
|
32
|
+
* - `HasValidLinks`: Validates external links via HTTP requests
|
|
33
|
+
* - `Trending`: Checks hashtag trending status via Bluesky API
|
|
34
|
+
*
|
|
35
|
+
* ### Composite Filters
|
|
36
|
+
* - `And`, `Or`: Logical composition
|
|
37
|
+
* - `Not`: Logical negation
|
|
38
|
+
*
|
|
39
|
+
* ## Error Handling
|
|
40
|
+
*
|
|
41
|
+
* Effectful filters use `FilterErrorPolicy` to determine behavior on failure:
|
|
42
|
+
* - `Include`: Treat errors as matching (include the post)
|
|
43
|
+
* - `Exclude`: Treat errors as non-matching (exclude the post)
|
|
44
|
+
* - `Retry`: Retry with exponential backoff
|
|
45
|
+
*
|
|
46
|
+
* ## Dependencies
|
|
47
|
+
*
|
|
48
|
+
* - `LinkValidator`: For validating external links
|
|
49
|
+
* - `TrendingTopics`: For checking trending hashtag status
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { Effect } from "effect";
|
|
54
|
+
* import { FilterRuntime } from "./services/filter-runtime.js";
|
|
55
|
+
* import { and, hashtag, author } from "./domain/filter.js";
|
|
56
|
+
*
|
|
57
|
+
* const program = Effect.gen(function* () {
|
|
58
|
+
* const runtime = yield* FilterRuntime;
|
|
59
|
+
*
|
|
60
|
+
* // Compile a filter expression
|
|
61
|
+
* const predicate = yield* runtime.evaluate(
|
|
62
|
+
* and(hashtag("tech"), author("@alice.bsky.social"))
|
|
63
|
+
* );
|
|
64
|
+
*
|
|
65
|
+
* // Evaluate against a post
|
|
66
|
+
* const matches = yield* predicate(post);
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @module services/filter-runtime
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { Chunk, Context, Duration, Effect, Layer, Schedule } from "effect";
|
|
74
|
+
import { FilterCompileError, FilterEvalError } from "../domain/errors.js";
|
|
75
|
+
import type { FilterExpr } from "../domain/filter.js";
|
|
76
|
+
import type { FilterErrorPolicy } from "../domain/policies.js";
|
|
77
|
+
import type { Post } from "../domain/post.js";
|
|
78
|
+
import type { FilterExplanation } from "../domain/filter-explain.js";
|
|
79
|
+
import type { LinkValidatorService } from "./link-validator.js";
|
|
80
|
+
import type { TrendingTopicsService } from "./trending-topics.js";
|
|
81
|
+
|
|
82
|
+
const regexMatches = (regex: RegExp, text: string) => {
|
|
83
|
+
if (regex.global || regex.sticky) {
|
|
84
|
+
return new RegExp(regex.source, regex.flags).test(text);
|
|
85
|
+
}
|
|
86
|
+
return regex.test(text);
|
|
87
|
+
};
|
|
88
|
+
import { LinkValidator } from "./link-validator.js";
|
|
89
|
+
import { TrendingTopics } from "./trending-topics.js";
|
|
90
|
+
import { FilterSettings } from "./filter-settings.js";
|
|
91
|
+
|
|
92
|
+
type Predicate = (post: Post) => Effect.Effect<boolean, FilterEvalError>;
|
|
93
|
+
type Explainer = (post: Post) => Effect.Effect<FilterExplanation, FilterEvalError>;
|
|
94
|
+
|
|
95
|
+
const embedTag = (embed: Post["embed"]): string | undefined => {
|
|
96
|
+
if (!embed || typeof embed !== "object" || !("_tag" in embed)) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const tag = (embed as { readonly _tag?: unknown })._tag;
|
|
100
|
+
return typeof tag === "string" ? tag : undefined;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const embedMediaTag = (embed: Post["embed"]): string | undefined => {
|
|
104
|
+
if (!embed || typeof embed !== "object" || !("_tag" in embed)) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const tag = (embed as { readonly _tag?: unknown })._tag;
|
|
108
|
+
if (tag !== "RecordWithMedia") {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const media = (embed as { readonly media?: unknown }).media;
|
|
112
|
+
if (!media || typeof media !== "object" || !("_tag" in media)) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const mediaTag = (media as { readonly _tag?: unknown })._tag;
|
|
116
|
+
return typeof mediaTag === "string" ? mediaTag : undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const hasExternalLink = (post: Post) => {
|
|
120
|
+
if (post.links.length > 0) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
const tag = embedTag(post.embed);
|
|
124
|
+
if (tag === "External") {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return embedMediaTag(post.embed) === "External";
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const hasImages = (post: Post) => {
|
|
131
|
+
const tag = embedTag(post.embed);
|
|
132
|
+
if (tag === "Images") {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return embedMediaTag(post.embed) === "Images";
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const hasVideo = (post: Post) => {
|
|
139
|
+
const tag = embedTag(post.embed);
|
|
140
|
+
if (tag === "Video") {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return embedMediaTag(post.embed) === "Video";
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const hasMedia = (post: Post) =>
|
|
147
|
+
hasImages(post) || hasVideo(post) || hasExternalLink(post);
|
|
148
|
+
|
|
149
|
+
const hasEmbed = (post: Post) =>
|
|
150
|
+
post.embed != null || post.recordEmbed != null;
|
|
151
|
+
|
|
152
|
+
const isRepost = (post: Post) => {
|
|
153
|
+
const reason = post.feed?.reason;
|
|
154
|
+
if (!reason || typeof reason !== "object") {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const tag = (reason as { readonly _tag?: unknown })._tag;
|
|
158
|
+
return tag === "ReasonRepost";
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const isQuote = (post: Post) => {
|
|
162
|
+
const tag = embedTag(post.embed);
|
|
163
|
+
return tag === "Record" || tag === "RecordWithMedia";
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const withPolicy = (
|
|
167
|
+
policy: FilterErrorPolicy,
|
|
168
|
+
effect: Effect.Effect<boolean, FilterEvalError>
|
|
169
|
+
): Effect.Effect<boolean, FilterEvalError> => {
|
|
170
|
+
switch (policy._tag) {
|
|
171
|
+
case "Include":
|
|
172
|
+
return effect.pipe(Effect.catchAll(() => Effect.succeed(true)));
|
|
173
|
+
case "Exclude":
|
|
174
|
+
return effect.pipe(Effect.catchAll(() => Effect.succeed(false)));
|
|
175
|
+
case "Retry": {
|
|
176
|
+
if (!Duration.isFinite(policy.baseDelay)) {
|
|
177
|
+
return Effect.fail(
|
|
178
|
+
FilterEvalError.make({ message: "Retry baseDelay must be finite" })
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const delay = policy.baseDelay;
|
|
182
|
+
const schedule = Schedule.addDelay(
|
|
183
|
+
Schedule.recurs(policy.maxRetries),
|
|
184
|
+
() => delay
|
|
185
|
+
);
|
|
186
|
+
return effect.pipe(Effect.retry(schedule));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const messageFromError = (error: unknown) => {
|
|
192
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
193
|
+
const message = (error as { readonly message?: unknown }).message;
|
|
194
|
+
if (typeof message === "string") return message;
|
|
195
|
+
}
|
|
196
|
+
return String(error);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const retryScheduleFor = (policy: Extract<FilterErrorPolicy, { _tag: "Retry" }>) => {
|
|
200
|
+
if (!Duration.isFinite(policy.baseDelay)) {
|
|
201
|
+
return FilterEvalError.make({ message: "Retry baseDelay must be finite" });
|
|
202
|
+
}
|
|
203
|
+
return Schedule.addDelay(Schedule.recurs(policy.maxRetries), () => policy.baseDelay);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const explainPolicy = <A>(
|
|
207
|
+
policy: FilterErrorPolicy,
|
|
208
|
+
effect: Effect.Effect<A, FilterEvalError>,
|
|
209
|
+
onSuccess: (value: A) => FilterExplanation,
|
|
210
|
+
onError: (error: FilterEvalError, policyTag: "Include" | "Exclude") => FilterExplanation
|
|
211
|
+
): Effect.Effect<FilterExplanation, FilterEvalError> => {
|
|
212
|
+
switch (policy._tag) {
|
|
213
|
+
case "Include":
|
|
214
|
+
case "Exclude":
|
|
215
|
+
return effect.pipe(
|
|
216
|
+
Effect.match({
|
|
217
|
+
onSuccess,
|
|
218
|
+
onFailure: (error) => onError(error, policy._tag)
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
case "Retry": {
|
|
222
|
+
const schedule = retryScheduleFor(policy);
|
|
223
|
+
if (schedule instanceof FilterEvalError) {
|
|
224
|
+
return Effect.fail(schedule);
|
|
225
|
+
}
|
|
226
|
+
return effect.pipe(Effect.retry(schedule), Effect.map(onSuccess));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const skippedNode = (expr: FilterExpr, reason: string): FilterExplanation => ({
|
|
232
|
+
_tag: expr._tag,
|
|
233
|
+
ok: false,
|
|
234
|
+
skipped: true,
|
|
235
|
+
detail: reason
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const buildExplanation = (
|
|
239
|
+
links: LinkValidatorService,
|
|
240
|
+
trending: TrendingTopicsService
|
|
241
|
+
): ((expr: FilterExpr) => Effect.Effect<Explainer, FilterCompileError>) =>
|
|
242
|
+
Effect.fn("FilterRuntime.buildExplanation")(function* (expr: FilterExpr) {
|
|
243
|
+
switch (expr._tag) {
|
|
244
|
+
case "All":
|
|
245
|
+
return (_post: Post) => Effect.succeed({ _tag: "All", ok: true });
|
|
246
|
+
case "None":
|
|
247
|
+
return (_post: Post) => Effect.succeed({ _tag: "None", ok: false });
|
|
248
|
+
case "Author":
|
|
249
|
+
return (post: Post) =>
|
|
250
|
+
Effect.succeed({
|
|
251
|
+
_tag: "Author",
|
|
252
|
+
ok: post.author === expr.handle,
|
|
253
|
+
detail: `author=${post.author}, expected=${expr.handle}`
|
|
254
|
+
});
|
|
255
|
+
case "Hashtag":
|
|
256
|
+
return (post: Post) => {
|
|
257
|
+
const matched = post.hashtags.find((tag) => tag === expr.tag);
|
|
258
|
+
return Effect.succeed({
|
|
259
|
+
_tag: "Hashtag",
|
|
260
|
+
ok: matched !== undefined,
|
|
261
|
+
detail: matched
|
|
262
|
+
? `matched=${matched}`
|
|
263
|
+
: `hashtags=${post.hashtags.join(",") || "none"}`
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
case "AuthorIn": {
|
|
267
|
+
const handles = new Set(expr.handles);
|
|
268
|
+
return (post: Post) =>
|
|
269
|
+
Effect.succeed({
|
|
270
|
+
_tag: "AuthorIn",
|
|
271
|
+
ok: handles.has(post.author),
|
|
272
|
+
detail: `author=${post.author}`
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
case "HashtagIn": {
|
|
276
|
+
const tags = new Set(expr.tags);
|
|
277
|
+
return (post: Post) => {
|
|
278
|
+
const matched = post.hashtags.find((tag) => tags.has(tag));
|
|
279
|
+
return Effect.succeed({
|
|
280
|
+
_tag: "HashtagIn",
|
|
281
|
+
ok: matched !== undefined,
|
|
282
|
+
detail: matched
|
|
283
|
+
? `matched=${matched}`
|
|
284
|
+
: `hashtags=${post.hashtags.join(",") || "none"}`
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
case "Contains": {
|
|
289
|
+
const needle = expr.caseSensitive ? expr.text : expr.text.toLowerCase();
|
|
290
|
+
return (post: Post) => {
|
|
291
|
+
const haystack = expr.caseSensitive ? post.text : post.text.toLowerCase();
|
|
292
|
+
const ok = haystack.includes(needle);
|
|
293
|
+
return Effect.succeed({
|
|
294
|
+
_tag: "Contains",
|
|
295
|
+
ok,
|
|
296
|
+
detail: `caseSensitive=${expr.caseSensitive ?? false}`
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
case "IsReply":
|
|
301
|
+
return (post: Post) =>
|
|
302
|
+
Effect.succeed({
|
|
303
|
+
_tag: "IsReply",
|
|
304
|
+
ok: !!post.reply,
|
|
305
|
+
detail: `reply=${Boolean(post.reply)}`
|
|
306
|
+
});
|
|
307
|
+
case "IsQuote":
|
|
308
|
+
return (post: Post) =>
|
|
309
|
+
Effect.succeed({
|
|
310
|
+
_tag: "IsQuote",
|
|
311
|
+
ok: isQuote(post),
|
|
312
|
+
detail: `quote=${isQuote(post)}`
|
|
313
|
+
});
|
|
314
|
+
case "IsRepost":
|
|
315
|
+
return (post: Post) =>
|
|
316
|
+
Effect.succeed({
|
|
317
|
+
_tag: "IsRepost",
|
|
318
|
+
ok: isRepost(post),
|
|
319
|
+
detail: `repost=${isRepost(post)}`
|
|
320
|
+
});
|
|
321
|
+
case "IsOriginal":
|
|
322
|
+
return (post: Post) => {
|
|
323
|
+
const ok = !post.reply && !isQuote(post) && !isRepost(post);
|
|
324
|
+
return Effect.succeed({
|
|
325
|
+
_tag: "IsOriginal",
|
|
326
|
+
ok,
|
|
327
|
+
detail: `reply=${Boolean(post.reply)}, quote=${isQuote(post)}, repost=${isRepost(post)}`
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
case "Engagement":
|
|
331
|
+
return (post: Post) => {
|
|
332
|
+
const metrics = post.metrics;
|
|
333
|
+
const likes = metrics?.likeCount ?? 0;
|
|
334
|
+
const reposts = metrics?.repostCount ?? 0;
|
|
335
|
+
const replies = metrics?.replyCount ?? 0;
|
|
336
|
+
const passes = (min: number | undefined, value: number) =>
|
|
337
|
+
min === undefined || value >= min;
|
|
338
|
+
const ok =
|
|
339
|
+
passes(expr.minLikes, likes) &&
|
|
340
|
+
passes(expr.minReposts, reposts) &&
|
|
341
|
+
passes(expr.minReplies, replies);
|
|
342
|
+
return Effect.succeed({
|
|
343
|
+
_tag: "Engagement",
|
|
344
|
+
ok,
|
|
345
|
+
detail: `likes=${likes}, reposts=${reposts}, replies=${replies}`
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
case "HasImages":
|
|
349
|
+
return (post: Post) =>
|
|
350
|
+
Effect.succeed({
|
|
351
|
+
_tag: "HasImages",
|
|
352
|
+
ok: hasImages(post),
|
|
353
|
+
detail: `hasImages=${hasImages(post)}`
|
|
354
|
+
});
|
|
355
|
+
case "HasVideo":
|
|
356
|
+
return (post: Post) =>
|
|
357
|
+
Effect.succeed({
|
|
358
|
+
_tag: "HasVideo",
|
|
359
|
+
ok: hasVideo(post),
|
|
360
|
+
detail: `hasVideo=${hasVideo(post)}`
|
|
361
|
+
});
|
|
362
|
+
case "HasLinks":
|
|
363
|
+
return (post: Post) =>
|
|
364
|
+
Effect.succeed({
|
|
365
|
+
_tag: "HasLinks",
|
|
366
|
+
ok: hasExternalLink(post),
|
|
367
|
+
detail: `links=${post.links.length}`
|
|
368
|
+
});
|
|
369
|
+
case "HasMedia":
|
|
370
|
+
return (post: Post) =>
|
|
371
|
+
Effect.succeed({
|
|
372
|
+
_tag: "HasMedia",
|
|
373
|
+
ok: hasMedia(post),
|
|
374
|
+
detail: `hasMedia=${hasMedia(post)}`
|
|
375
|
+
});
|
|
376
|
+
case "HasEmbed":
|
|
377
|
+
return (post: Post) =>
|
|
378
|
+
Effect.succeed({
|
|
379
|
+
_tag: "HasEmbed",
|
|
380
|
+
ok: hasEmbed(post),
|
|
381
|
+
detail: `hasEmbed=${hasEmbed(post)}`
|
|
382
|
+
});
|
|
383
|
+
case "Language": {
|
|
384
|
+
const langs = new Set(expr.langs.map((lang) => lang.toLowerCase()));
|
|
385
|
+
return (post: Post) => {
|
|
386
|
+
if (!post.langs || post.langs.length === 0) {
|
|
387
|
+
return Effect.succeed({
|
|
388
|
+
_tag: "Language",
|
|
389
|
+
ok: false,
|
|
390
|
+
detail: "langs=none"
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
const matched = post.langs.find((lang) => langs.has(lang.toLowerCase()));
|
|
394
|
+
return Effect.succeed({
|
|
395
|
+
_tag: "Language",
|
|
396
|
+
ok: matched !== undefined,
|
|
397
|
+
detail: matched
|
|
398
|
+
? `matched=${matched}`
|
|
399
|
+
: `langs=${post.langs.join(",")}`
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
case "Regex": {
|
|
404
|
+
if (expr.patterns.length === 0) {
|
|
405
|
+
return yield* FilterCompileError.make({
|
|
406
|
+
message: "Regex patterns must contain at least one entry"
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const compiled = yield* Effect.forEach(
|
|
410
|
+
expr.patterns,
|
|
411
|
+
(pattern) =>
|
|
412
|
+
Effect.try({
|
|
413
|
+
try: () => new RegExp(pattern, expr.flags),
|
|
414
|
+
catch: (error) =>
|
|
415
|
+
FilterCompileError.make({
|
|
416
|
+
message: `Invalid regex "${pattern}": ${messageFromError(error)}`
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
);
|
|
420
|
+
return (post: Post) => {
|
|
421
|
+
const matched = compiled.find((regex) => regexMatches(regex, post.text));
|
|
422
|
+
return Effect.succeed({
|
|
423
|
+
_tag: "Regex",
|
|
424
|
+
ok: matched !== undefined,
|
|
425
|
+
detail: matched
|
|
426
|
+
? `matched=${matched.source}`
|
|
427
|
+
: `patterns=${expr.patterns.join(",")}`
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
case "DateRange":
|
|
432
|
+
return (post: Post) => {
|
|
433
|
+
const created = post.createdAt.getTime();
|
|
434
|
+
const ok =
|
|
435
|
+
created >= expr.start.getTime() && created <= expr.end.getTime();
|
|
436
|
+
return Effect.succeed({
|
|
437
|
+
_tag: "DateRange",
|
|
438
|
+
ok,
|
|
439
|
+
detail: `createdAt=${post.createdAt.toISOString()}`
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
case "And": {
|
|
443
|
+
const left = yield* buildExplanation(links, trending)(expr.left);
|
|
444
|
+
const right = yield* buildExplanation(links, trending)(expr.right);
|
|
445
|
+
return (post: Post) =>
|
|
446
|
+
left(post).pipe(
|
|
447
|
+
Effect.flatMap((leftResult) => {
|
|
448
|
+
if (!leftResult.ok) {
|
|
449
|
+
return Effect.succeed({
|
|
450
|
+
_tag: "And",
|
|
451
|
+
ok: false,
|
|
452
|
+
children: [
|
|
453
|
+
leftResult,
|
|
454
|
+
skippedNode(expr.right, "Skipped because left side was false.")
|
|
455
|
+
]
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return right(post).pipe(
|
|
459
|
+
Effect.map((rightResult) => ({
|
|
460
|
+
_tag: "And",
|
|
461
|
+
ok: rightResult.ok,
|
|
462
|
+
children: [leftResult, rightResult]
|
|
463
|
+
}))
|
|
464
|
+
);
|
|
465
|
+
})
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
case "Or": {
|
|
469
|
+
const left = yield* buildExplanation(links, trending)(expr.left);
|
|
470
|
+
const right = yield* buildExplanation(links, trending)(expr.right);
|
|
471
|
+
return (post: Post) =>
|
|
472
|
+
left(post).pipe(
|
|
473
|
+
Effect.flatMap((leftResult) => {
|
|
474
|
+
if (leftResult.ok) {
|
|
475
|
+
return Effect.succeed({
|
|
476
|
+
_tag: "Or",
|
|
477
|
+
ok: true,
|
|
478
|
+
children: [
|
|
479
|
+
leftResult,
|
|
480
|
+
skippedNode(expr.right, "Skipped because left side was true.")
|
|
481
|
+
]
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return right(post).pipe(
|
|
485
|
+
Effect.map((rightResult) => ({
|
|
486
|
+
_tag: "Or",
|
|
487
|
+
ok: rightResult.ok,
|
|
488
|
+
children: [leftResult, rightResult]
|
|
489
|
+
}))
|
|
490
|
+
);
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
case "Not": {
|
|
495
|
+
const inner = yield* buildExplanation(links, trending)(expr.expr);
|
|
496
|
+
return (post: Post) =>
|
|
497
|
+
inner(post).pipe(
|
|
498
|
+
Effect.map((innerResult) => ({
|
|
499
|
+
_tag: "Not",
|
|
500
|
+
ok: !innerResult.ok,
|
|
501
|
+
children: [innerResult]
|
|
502
|
+
}))
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
case "HasValidLinks": {
|
|
506
|
+
return (post: Post) => {
|
|
507
|
+
const urls = post.links.map((link) => link.toString());
|
|
508
|
+
return explainPolicy(
|
|
509
|
+
expr.onError,
|
|
510
|
+
links.hasValidLink(urls),
|
|
511
|
+
(ok) => ({
|
|
512
|
+
_tag: "HasValidLinks",
|
|
513
|
+
ok,
|
|
514
|
+
detail: `links=${urls.length}, policy=${expr.onError._tag}`
|
|
515
|
+
}),
|
|
516
|
+
(error, policyTag) => ({
|
|
517
|
+
_tag: "HasValidLinks",
|
|
518
|
+
ok: policyTag === "Include",
|
|
519
|
+
detail: `error=${messageFromError(error)}, policy=${policyTag}`
|
|
520
|
+
})
|
|
521
|
+
);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
case "Trending": {
|
|
525
|
+
return (_post: Post) =>
|
|
526
|
+
explainPolicy(
|
|
527
|
+
expr.onError,
|
|
528
|
+
trending.isTrending(expr.tag),
|
|
529
|
+
(ok) => ({
|
|
530
|
+
_tag: "Trending",
|
|
531
|
+
ok,
|
|
532
|
+
detail: `tag=${expr.tag}, policy=${expr.onError._tag}`
|
|
533
|
+
}),
|
|
534
|
+
(error, policyTag) => ({
|
|
535
|
+
_tag: "Trending",
|
|
536
|
+
ok: policyTag === "Include",
|
|
537
|
+
detail: `error=${messageFromError(error)}, policy=${policyTag}`
|
|
538
|
+
})
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
default:
|
|
542
|
+
return yield* FilterCompileError.make({
|
|
543
|
+
message: `Unknown filter tag: ${(expr as { _tag: string })._tag}`
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const buildPredicate = (
|
|
549
|
+
links: LinkValidatorService,
|
|
550
|
+
trending: TrendingTopicsService
|
|
551
|
+
): ((expr: FilterExpr) => Effect.Effect<Predicate, FilterCompileError>) =>
|
|
552
|
+
Effect.fn("FilterRuntime.buildPredicate")(function* (expr: FilterExpr) {
|
|
553
|
+
switch (expr._tag) {
|
|
554
|
+
case "All":
|
|
555
|
+
return (_post: Post) => Effect.succeed(true);
|
|
556
|
+
case "None":
|
|
557
|
+
return (_post: Post) => Effect.succeed(false);
|
|
558
|
+
case "Author":
|
|
559
|
+
return (post: Post) =>
|
|
560
|
+
Effect.succeed(post.author === expr.handle);
|
|
561
|
+
case "Hashtag":
|
|
562
|
+
return (post: Post) =>
|
|
563
|
+
Effect.succeed(post.hashtags.some((tag) => tag === expr.tag));
|
|
564
|
+
case "AuthorIn": {
|
|
565
|
+
const handles = new Set(expr.handles);
|
|
566
|
+
return (post: Post) =>
|
|
567
|
+
Effect.succeed(handles.has(post.author));
|
|
568
|
+
}
|
|
569
|
+
case "HashtagIn": {
|
|
570
|
+
const tags = new Set(expr.tags);
|
|
571
|
+
return (post: Post) =>
|
|
572
|
+
Effect.succeed(post.hashtags.some((tag) => tags.has(tag)));
|
|
573
|
+
}
|
|
574
|
+
case "Contains": {
|
|
575
|
+
const needle = expr.caseSensitive ? expr.text : expr.text.toLowerCase();
|
|
576
|
+
return (post: Post) => {
|
|
577
|
+
const haystack = expr.caseSensitive ? post.text : post.text.toLowerCase();
|
|
578
|
+
return Effect.succeed(haystack.includes(needle));
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
case "IsReply":
|
|
582
|
+
return (post: Post) => Effect.succeed(!!post.reply);
|
|
583
|
+
case "IsQuote":
|
|
584
|
+
return (post: Post) => Effect.succeed(isQuote(post));
|
|
585
|
+
case "IsRepost":
|
|
586
|
+
return (post: Post) => Effect.succeed(isRepost(post));
|
|
587
|
+
case "IsOriginal":
|
|
588
|
+
return (post: Post) =>
|
|
589
|
+
Effect.succeed(!post.reply && !isQuote(post) && !isRepost(post));
|
|
590
|
+
case "Engagement":
|
|
591
|
+
return (post: Post) => {
|
|
592
|
+
const metrics = post.metrics;
|
|
593
|
+
const likes = metrics?.likeCount ?? 0;
|
|
594
|
+
const reposts = metrics?.repostCount ?? 0;
|
|
595
|
+
const replies = metrics?.replyCount ?? 0;
|
|
596
|
+
const passes = (min: number | undefined, value: number) =>
|
|
597
|
+
min === undefined || value >= min;
|
|
598
|
+
return Effect.succeed(
|
|
599
|
+
passes(expr.minLikes, likes) &&
|
|
600
|
+
passes(expr.minReposts, reposts) &&
|
|
601
|
+
passes(expr.minReplies, replies)
|
|
602
|
+
);
|
|
603
|
+
};
|
|
604
|
+
case "HasImages":
|
|
605
|
+
return (post: Post) => Effect.succeed(hasImages(post));
|
|
606
|
+
case "HasVideo":
|
|
607
|
+
return (post: Post) => Effect.succeed(hasVideo(post));
|
|
608
|
+
case "HasLinks":
|
|
609
|
+
return (post: Post) =>
|
|
610
|
+
Effect.succeed(hasExternalLink(post));
|
|
611
|
+
case "HasMedia":
|
|
612
|
+
return (post: Post) => Effect.succeed(hasMedia(post));
|
|
613
|
+
case "HasEmbed":
|
|
614
|
+
return (post: Post) => Effect.succeed(hasEmbed(post));
|
|
615
|
+
case "Language": {
|
|
616
|
+
const langs = new Set(expr.langs.map((lang) => lang.toLowerCase()));
|
|
617
|
+
return (post: Post) => {
|
|
618
|
+
if (!post.langs || post.langs.length === 0) {
|
|
619
|
+
return Effect.succeed(false);
|
|
620
|
+
}
|
|
621
|
+
return Effect.succeed(
|
|
622
|
+
post.langs.some((lang) => langs.has(lang.toLowerCase()))
|
|
623
|
+
);
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
case "Regex": {
|
|
627
|
+
if (expr.patterns.length === 0) {
|
|
628
|
+
return yield* FilterCompileError.make({
|
|
629
|
+
message: "Regex patterns must contain at least one entry"
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const compiled = yield* Effect.forEach(
|
|
633
|
+
expr.patterns,
|
|
634
|
+
(pattern) =>
|
|
635
|
+
Effect.try({
|
|
636
|
+
try: () => new RegExp(pattern, expr.flags),
|
|
637
|
+
catch: (error) =>
|
|
638
|
+
FilterCompileError.make({
|
|
639
|
+
message: `Invalid regex "${pattern}": ${messageFromError(error)}`
|
|
640
|
+
})
|
|
641
|
+
})
|
|
642
|
+
);
|
|
643
|
+
return (post: Post) =>
|
|
644
|
+
Effect.succeed(
|
|
645
|
+
compiled.some((regex) => regexMatches(regex, post.text))
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
case "DateRange":
|
|
649
|
+
return (post: Post) => {
|
|
650
|
+
const created = post.createdAt.getTime();
|
|
651
|
+
return Effect.succeed(
|
|
652
|
+
created >= expr.start.getTime() && created <= expr.end.getTime()
|
|
653
|
+
);
|
|
654
|
+
};
|
|
655
|
+
case "And": {
|
|
656
|
+
const left = yield* buildPredicate(links, trending)(expr.left);
|
|
657
|
+
const right = yield* buildPredicate(links, trending)(expr.right);
|
|
658
|
+
return (post: Post) =>
|
|
659
|
+
left(post).pipe(
|
|
660
|
+
Effect.flatMap((ok) =>
|
|
661
|
+
ok ? right(post) : Effect.succeed(false)
|
|
662
|
+
)
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
case "Or": {
|
|
666
|
+
const left = yield* buildPredicate(links, trending)(expr.left);
|
|
667
|
+
const right = yield* buildPredicate(links, trending)(expr.right);
|
|
668
|
+
return (post: Post) =>
|
|
669
|
+
left(post).pipe(
|
|
670
|
+
Effect.flatMap((ok) =>
|
|
671
|
+
ok ? Effect.succeed(true) : right(post)
|
|
672
|
+
)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
case "Not": {
|
|
676
|
+
const inner = yield* buildPredicate(links, trending)(expr.expr);
|
|
677
|
+
return (post: Post) =>
|
|
678
|
+
inner(post).pipe(Effect.map((ok) => !ok));
|
|
679
|
+
}
|
|
680
|
+
case "HasValidLinks": {
|
|
681
|
+
return (post: Post) =>
|
|
682
|
+
withPolicy(
|
|
683
|
+
expr.onError,
|
|
684
|
+
links.hasValidLink(post.links.map((link) => link.toString()))
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
case "Trending": {
|
|
688
|
+
return (_post: Post) =>
|
|
689
|
+
withPolicy(expr.onError, trending.isTrending(expr.tag));
|
|
690
|
+
}
|
|
691
|
+
default:
|
|
692
|
+
return yield* FilterCompileError.make({
|
|
693
|
+
message: `Unknown filter tag: ${(expr as { _tag: string })._tag}`
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Service for compiling and evaluating filter expressions.
|
|
700
|
+
*
|
|
701
|
+
* Provides methods to compile FilterExpr AST into executable predicates,
|
|
702
|
+
* with support for batch evaluation and explanation mode.
|
|
703
|
+
*
|
|
704
|
+
* ## Methods
|
|
705
|
+
*
|
|
706
|
+
* - `evaluate`: Compile a filter into a predicate function
|
|
707
|
+
* - `evaluateWithMetadata`: Like evaluate but returns detailed match results
|
|
708
|
+
* - `evaluateBatch`: Efficiently evaluate a filter against multiple posts
|
|
709
|
+
* - `explain`: Get detailed explanations for why posts match or don't match
|
|
710
|
+
*
|
|
711
|
+
* @example
|
|
712
|
+
* ```ts
|
|
713
|
+
* const runtime = yield* FilterRuntime;
|
|
714
|
+
*
|
|
715
|
+
* // Simple evaluation
|
|
716
|
+
* const predicate = yield* runtime.evaluate(hashtag("tech"));
|
|
717
|
+
* const matches = yield* predicate(post);
|
|
718
|
+
*
|
|
719
|
+
* // Batch evaluation for performance
|
|
720
|
+
* const batchPredicate = yield* runtime.evaluateBatch(filter);
|
|
721
|
+
* const results = yield* batchPredicate(Chunk.fromIterable(posts));
|
|
722
|
+
* ```
|
|
723
|
+
*/
|
|
724
|
+
export class FilterRuntime extends Context.Tag("@skygent/FilterRuntime")<
|
|
725
|
+
FilterRuntime,
|
|
726
|
+
{
|
|
727
|
+
/**
|
|
728
|
+
* Compiles a filter expression into an executable predicate.
|
|
729
|
+
*
|
|
730
|
+
* @param expr - The filter expression to compile
|
|
731
|
+
* @returns Effect that yields a predicate function
|
|
732
|
+
*/
|
|
733
|
+
readonly evaluate: (
|
|
734
|
+
expr: FilterExpr
|
|
735
|
+
) => Effect.Effect<Predicate, FilterCompileError>;
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Like evaluate, but returns detailed match results with metadata.
|
|
739
|
+
*
|
|
740
|
+
* @param expr - The filter expression to compile
|
|
741
|
+
* @returns Effect that yields a predicate returning { ok: boolean }
|
|
742
|
+
*/
|
|
743
|
+
readonly evaluateWithMetadata: (
|
|
744
|
+
expr: FilterExpr
|
|
745
|
+
) => Effect.Effect<
|
|
746
|
+
(post: Post) => Effect.Effect<
|
|
747
|
+
{ readonly ok: boolean },
|
|
748
|
+
FilterEvalError
|
|
749
|
+
>,
|
|
750
|
+
FilterCompileError
|
|
751
|
+
>;
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Compiles a filter for efficient batch evaluation.
|
|
755
|
+
*
|
|
756
|
+
* Batch evaluation processes multiple posts concurrently with
|
|
757
|
+
* automatic request batching for effectful filters.
|
|
758
|
+
*
|
|
759
|
+
* @param expr - The filter expression to compile
|
|
760
|
+
* @returns Effect that yields a batch predicate
|
|
761
|
+
*/
|
|
762
|
+
readonly evaluateBatch: (
|
|
763
|
+
expr: FilterExpr
|
|
764
|
+
) => Effect.Effect<
|
|
765
|
+
(posts: Chunk.Chunk<Post>) => Effect.Effect<Chunk.Chunk<boolean>, FilterEvalError>,
|
|
766
|
+
FilterCompileError
|
|
767
|
+
>;
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Compiles a filter into an explainer function.
|
|
771
|
+
*
|
|
772
|
+
* Explainer functions provide detailed reasoning for filter decisions,
|
|
773
|
+
* useful for debugging and user feedback.
|
|
774
|
+
*
|
|
775
|
+
* @param expr - The filter expression to compile
|
|
776
|
+
* @returns Effect that yields an explainer function
|
|
777
|
+
*/
|
|
778
|
+
readonly explain: (
|
|
779
|
+
expr: FilterExpr
|
|
780
|
+
) => Effect.Effect<Explainer, FilterCompileError>;
|
|
781
|
+
}
|
|
782
|
+
>() {
|
|
783
|
+
static readonly layer = Layer.effect(
|
|
784
|
+
FilterRuntime,
|
|
785
|
+
Effect.gen(function* () {
|
|
786
|
+
const links = yield* LinkValidator;
|
|
787
|
+
const trending = yield* TrendingTopics;
|
|
788
|
+
const settings = yield* FilterSettings;
|
|
789
|
+
const evaluate = Effect.fn("FilterRuntime.evaluate")((expr: FilterExpr) =>
|
|
790
|
+
buildPredicate(links, trending)(expr)
|
|
791
|
+
);
|
|
792
|
+
const evaluateWithMetadata = Effect.fn(
|
|
793
|
+
"FilterRuntime.evaluateWithMetadata"
|
|
794
|
+
)((expr: FilterExpr) =>
|
|
795
|
+
buildPredicate(links, trending)(expr).pipe(
|
|
796
|
+
Effect.map((predicate) => (post: Post) =>
|
|
797
|
+
predicate(post).pipe(Effect.map((ok) => ({ ok })))
|
|
798
|
+
)
|
|
799
|
+
)
|
|
800
|
+
);
|
|
801
|
+
const evaluateBatch = Effect.fn("FilterRuntime.evaluateBatch")((expr: FilterExpr) =>
|
|
802
|
+
buildPredicate(links, trending)(expr).pipe(
|
|
803
|
+
Effect.map((predicate) => (posts: Chunk.Chunk<Post>) =>
|
|
804
|
+
Effect.all(Array.from(posts, (post) => predicate(post)), {
|
|
805
|
+
batching: true,
|
|
806
|
+
concurrency: settings.concurrency
|
|
807
|
+
}).pipe(
|
|
808
|
+
Effect.map(Chunk.fromIterable),
|
|
809
|
+
Effect.withRequestBatching(true)
|
|
810
|
+
)
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
);
|
|
814
|
+
const explain = Effect.fn("FilterRuntime.explain")((expr: FilterExpr) =>
|
|
815
|
+
buildExplanation(links, trending)(expr)
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
return FilterRuntime.of({ evaluate, evaluateWithMetadata, evaluateBatch, explain });
|
|
819
|
+
})
|
|
820
|
+
);
|
|
821
|
+
}
|