@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,797 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import * as Monoid from "@effect/typeclass/Monoid";
|
|
3
|
+
import * as Semigroup from "@effect/typeclass/Semigroup";
|
|
4
|
+
import { Handle, Hashtag, Timestamp } from "./primitives.js";
|
|
5
|
+
import { FilterErrorPolicy } from "./policies.js";
|
|
6
|
+
|
|
7
|
+
const required = <A, I, R>(schema: Schema.Schema<A, I, R>, message: string) =>
|
|
8
|
+
Schema.propertySignature(schema).annotations({
|
|
9
|
+
missingMessage: () => message
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Filter that matches all posts.
|
|
14
|
+
*
|
|
15
|
+
* Used as an identity element for filter composition.
|
|
16
|
+
*/
|
|
17
|
+
export interface FilterAll {
|
|
18
|
+
readonly _tag: "All";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Filter that matches no posts.
|
|
23
|
+
*
|
|
24
|
+
* Used to exclude everything in filter composition.
|
|
25
|
+
*/
|
|
26
|
+
export interface FilterNone {
|
|
27
|
+
readonly _tag: "None";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Logical AND composition of two filters.
|
|
32
|
+
*
|
|
33
|
+
* A post matches if both left and right filters match.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* and(author("@alice.bsky.social"), hashtag("tech"))
|
|
38
|
+
* // Matches posts by @alice.bsky.social containing #tech
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export interface FilterAnd {
|
|
42
|
+
readonly _tag: "And";
|
|
43
|
+
/** The left filter expression */
|
|
44
|
+
readonly left: FilterExpr;
|
|
45
|
+
/** The right filter expression */
|
|
46
|
+
readonly right: FilterExpr;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Logical OR composition of two filters.
|
|
51
|
+
*
|
|
52
|
+
* A post matches if either left or right filter matches.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* or(hashtag("javascript"), hashtag("typescript"))
|
|
57
|
+
* // Matches posts with either #javascript or #typescript
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export interface FilterOr {
|
|
61
|
+
readonly _tag: "Or";
|
|
62
|
+
/** The left filter expression */
|
|
63
|
+
readonly left: FilterExpr;
|
|
64
|
+
/** The right filter expression */
|
|
65
|
+
readonly right: FilterExpr;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Logical NOT of a filter.
|
|
70
|
+
*
|
|
71
|
+
* A post matches if the wrapped filter does NOT match.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* not(isReply())
|
|
76
|
+
* // Matches posts that are NOT replies
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export interface FilterNot {
|
|
80
|
+
readonly _tag: "Not";
|
|
81
|
+
/** The filter expression to negate */
|
|
82
|
+
readonly expr: FilterExpr;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Filter posts by a specific author handle.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* author("@alice.bsky.social")
|
|
91
|
+
* // Matches all posts by @alice.bsky.social
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export interface FilterAuthor {
|
|
95
|
+
readonly _tag: "Author";
|
|
96
|
+
/** The author's handle (with or without @ prefix) */
|
|
97
|
+
readonly handle: Handle;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Filter posts containing a specific hashtag.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* hashtag("tech")
|
|
106
|
+
* // Matches posts containing #tech
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export interface FilterHashtag {
|
|
110
|
+
readonly _tag: "Hashtag";
|
|
111
|
+
/** The hashtag to match (without # prefix) */
|
|
112
|
+
readonly tag: Hashtag;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Filter posts by multiple author handles (matches any).
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* authorIn(["@alice.bsky.social", "@bob.bsky.social"])
|
|
121
|
+
* // Matches posts by either @alice.bsky.social or @bob.bsky.social
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export interface FilterAuthorIn {
|
|
125
|
+
readonly _tag: "AuthorIn";
|
|
126
|
+
/** Array of author handles to match */
|
|
127
|
+
readonly handles: ReadonlyArray<Handle>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Filter posts containing any of multiple hashtags.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* hashtagIn(["javascript", "typescript", "nodejs"])
|
|
136
|
+
* // Matches posts with any of the specified hashtags
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export interface FilterHashtagIn {
|
|
140
|
+
readonly _tag: "HashtagIn";
|
|
141
|
+
/** Array of hashtags to match (without # prefix) */
|
|
142
|
+
readonly tags: ReadonlyArray<Hashtag>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Filter posts containing specific text.
|
|
147
|
+
*
|
|
148
|
+
* Performs substring matching on the post text.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* contains("skygent", { caseSensitive: false })
|
|
153
|
+
* // Matches posts containing "skygent", "SkyGent", etc.
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export interface FilterContains {
|
|
157
|
+
readonly _tag: "Contains";
|
|
158
|
+
/** The text to search for */
|
|
159
|
+
readonly text: string;
|
|
160
|
+
/** Whether matching should be case-sensitive (default: false) */
|
|
161
|
+
readonly caseSensitive?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Filter posts that are replies to other posts.
|
|
166
|
+
*/
|
|
167
|
+
export interface FilterIsReply {
|
|
168
|
+
readonly _tag: "IsReply";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Filter posts that quote other posts.
|
|
173
|
+
*/
|
|
174
|
+
export interface FilterIsQuote {
|
|
175
|
+
readonly _tag: "IsQuote";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Filter posts that are reposts.
|
|
180
|
+
*/
|
|
181
|
+
export interface FilterIsRepost {
|
|
182
|
+
readonly _tag: "IsRepost";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Filter posts that are original content (not reposts).
|
|
187
|
+
*/
|
|
188
|
+
export interface FilterIsOriginal {
|
|
189
|
+
readonly _tag: "IsOriginal";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Filter posts by engagement metrics (likes, reposts, replies).
|
|
194
|
+
*
|
|
195
|
+
* Requires at least one threshold to be specified.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* engagement({ minLikes: 10, minReposts: 5 })
|
|
200
|
+
* // Matches posts with at least 10 likes AND 5 reposts
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
export interface FilterEngagement {
|
|
204
|
+
readonly _tag: "Engagement";
|
|
205
|
+
/** Minimum number of likes required */
|
|
206
|
+
readonly minLikes?: number;
|
|
207
|
+
/** Minimum number of reposts required */
|
|
208
|
+
readonly minReposts?: number;
|
|
209
|
+
/** Minimum number of replies required */
|
|
210
|
+
readonly minReplies?: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Filter posts containing images.
|
|
215
|
+
*/
|
|
216
|
+
export interface FilterHasImages {
|
|
217
|
+
readonly _tag: "HasImages";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Filter posts containing video.
|
|
222
|
+
*/
|
|
223
|
+
export interface FilterHasVideo {
|
|
224
|
+
readonly _tag: "HasVideo";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Filter posts containing external links.
|
|
229
|
+
*/
|
|
230
|
+
export interface FilterHasLinks {
|
|
231
|
+
readonly _tag: "HasLinks";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Filter posts containing any media (images, video, or external links).
|
|
236
|
+
*/
|
|
237
|
+
export interface FilterHasMedia {
|
|
238
|
+
readonly _tag: "HasMedia";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Filter posts containing any embed (media, records, or external links).
|
|
243
|
+
*/
|
|
244
|
+
export interface FilterHasEmbed {
|
|
245
|
+
readonly _tag: "HasEmbed";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Filter posts by language codes.
|
|
250
|
+
*
|
|
251
|
+
* Matches posts that have any of the specified languages in their `langs` field.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* language(["en", "es"])
|
|
256
|
+
* // Matches posts marked as English or Spanish
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
export interface FilterLanguage {
|
|
260
|
+
readonly _tag: "Language";
|
|
261
|
+
/** Array of ISO 639-1 language codes */
|
|
262
|
+
readonly langs: ReadonlyArray<string>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Filter posts using regular expression patterns.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```ts
|
|
270
|
+
* regex(["\\bnodejs\\b", "\\bnode\\.js\\b"], "i")
|
|
271
|
+
* // Matches posts containing "nodejs" or "node.js" (case-insensitive)
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
export interface FilterRegex {
|
|
275
|
+
readonly _tag: "Regex";
|
|
276
|
+
/** One or more regex patterns to match */
|
|
277
|
+
readonly patterns: ReadonlyArray<string>;
|
|
278
|
+
/** Regex flags (e.g., "i" for case-insensitive, "g" for global) */
|
|
279
|
+
readonly flags?: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Filter posts by creation date range.
|
|
284
|
+
*
|
|
285
|
+
* @example
|
|
286
|
+
* ```ts
|
|
287
|
+
* dateRange("2024-01-01", "2024-12-31")
|
|
288
|
+
* // Matches posts created in 2024
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
export interface FilterDateRange {
|
|
292
|
+
readonly _tag: "DateRange";
|
|
293
|
+
/** Start of the date range (inclusive) */
|
|
294
|
+
readonly start: Timestamp;
|
|
295
|
+
/** End of the date range (inclusive) */
|
|
296
|
+
readonly end: Timestamp;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Filter posts that have valid external links.
|
|
301
|
+
*
|
|
302
|
+
* This is an effectful filter that may perform HTTP requests to validate links.
|
|
303
|
+
*
|
|
304
|
+
* @example
|
|
305
|
+
* ```ts
|
|
306
|
+
* hasValidLinks({ onError: { _tag: "Exclude" } })
|
|
307
|
+
* // Matches posts where all external links are valid (404s are excluded)
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
export interface FilterHasValidLinks {
|
|
311
|
+
readonly _tag: "HasValidLinks";
|
|
312
|
+
/** Policy for handling validation errors */
|
|
313
|
+
readonly onError: FilterErrorPolicy;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Filter for posts about trending topics.
|
|
318
|
+
*
|
|
319
|
+
* This is an effectful filter that checks hashtag trending status.
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* ```ts
|
|
323
|
+
* trending("tech", { onError: { _tag: "Include" } })
|
|
324
|
+
* // Matches posts with #tech when it's trending
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
export interface FilterTrending {
|
|
328
|
+
readonly _tag: "Trending";
|
|
329
|
+
/** The hashtag to check for trending status */
|
|
330
|
+
readonly tag: Hashtag;
|
|
331
|
+
/** Policy for handling errors (e.g., API failures) */
|
|
332
|
+
readonly onError: FilterErrorPolicy;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The complete set of filter expressions supported by Skygent.
|
|
337
|
+
*
|
|
338
|
+
* Filter expressions can be combined using `and`, `or`, and `not` to create
|
|
339
|
+
* complex filtering logic. They are used to determine which posts should be
|
|
340
|
+
* stored, output, or displayed.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```ts
|
|
344
|
+
* and(
|
|
345
|
+
* hashtag("tech"),
|
|
346
|
+
* or(
|
|
347
|
+
* author("@alice.bsky.social"),
|
|
348
|
+
* engagement({ minLikes: 100 })
|
|
349
|
+
* ),
|
|
350
|
+
* not(isReply())
|
|
351
|
+
* )
|
|
352
|
+
* // Matches tech posts by @alice.bsky.social OR tech posts with 100+ likes,
|
|
353
|
+
* // excluding replies
|
|
354
|
+
* ```
|
|
355
|
+
*/
|
|
356
|
+
export type FilterExpr =
|
|
357
|
+
| FilterAll
|
|
358
|
+
| FilterNone
|
|
359
|
+
| FilterAnd
|
|
360
|
+
| FilterOr
|
|
361
|
+
| FilterNot
|
|
362
|
+
| FilterAuthor
|
|
363
|
+
| FilterHashtag
|
|
364
|
+
| FilterAuthorIn
|
|
365
|
+
| FilterHashtagIn
|
|
366
|
+
| FilterContains
|
|
367
|
+
| FilterIsReply
|
|
368
|
+
| FilterIsQuote
|
|
369
|
+
| FilterIsRepost
|
|
370
|
+
| FilterIsOriginal
|
|
371
|
+
| FilterEngagement
|
|
372
|
+
| FilterHasImages
|
|
373
|
+
| FilterHasVideo
|
|
374
|
+
| FilterHasLinks
|
|
375
|
+
| FilterHasMedia
|
|
376
|
+
| FilterHasEmbed
|
|
377
|
+
| FilterLanguage
|
|
378
|
+
| FilterRegex
|
|
379
|
+
| FilterDateRange
|
|
380
|
+
| FilterHasValidLinks
|
|
381
|
+
| FilterTrending;
|
|
382
|
+
|
|
383
|
+
interface FilterAllEncoded {
|
|
384
|
+
readonly _tag: "All";
|
|
385
|
+
}
|
|
386
|
+
interface FilterNoneEncoded {
|
|
387
|
+
readonly _tag: "None";
|
|
388
|
+
}
|
|
389
|
+
interface FilterAndEncoded {
|
|
390
|
+
readonly _tag: "And";
|
|
391
|
+
readonly left: FilterExprEncoded;
|
|
392
|
+
readonly right: FilterExprEncoded;
|
|
393
|
+
}
|
|
394
|
+
interface FilterOrEncoded {
|
|
395
|
+
readonly _tag: "Or";
|
|
396
|
+
readonly left: FilterExprEncoded;
|
|
397
|
+
readonly right: FilterExprEncoded;
|
|
398
|
+
}
|
|
399
|
+
interface FilterNotEncoded {
|
|
400
|
+
readonly _tag: "Not";
|
|
401
|
+
readonly expr: FilterExprEncoded;
|
|
402
|
+
}
|
|
403
|
+
interface FilterAuthorEncoded {
|
|
404
|
+
readonly _tag: "Author";
|
|
405
|
+
readonly handle: string;
|
|
406
|
+
}
|
|
407
|
+
interface FilterHashtagEncoded {
|
|
408
|
+
readonly _tag: "Hashtag";
|
|
409
|
+
readonly tag: string;
|
|
410
|
+
}
|
|
411
|
+
interface FilterAuthorInEncoded {
|
|
412
|
+
readonly _tag: "AuthorIn";
|
|
413
|
+
readonly handles: ReadonlyArray<string>;
|
|
414
|
+
}
|
|
415
|
+
interface FilterHashtagInEncoded {
|
|
416
|
+
readonly _tag: "HashtagIn";
|
|
417
|
+
readonly tags: ReadonlyArray<string>;
|
|
418
|
+
}
|
|
419
|
+
interface FilterContainsEncoded {
|
|
420
|
+
readonly _tag: "Contains";
|
|
421
|
+
readonly text: string;
|
|
422
|
+
readonly caseSensitive?: boolean;
|
|
423
|
+
}
|
|
424
|
+
interface FilterIsReplyEncoded {
|
|
425
|
+
readonly _tag: "IsReply";
|
|
426
|
+
}
|
|
427
|
+
interface FilterIsQuoteEncoded {
|
|
428
|
+
readonly _tag: "IsQuote";
|
|
429
|
+
}
|
|
430
|
+
interface FilterIsRepostEncoded {
|
|
431
|
+
readonly _tag: "IsRepost";
|
|
432
|
+
}
|
|
433
|
+
interface FilterIsOriginalEncoded {
|
|
434
|
+
readonly _tag: "IsOriginal";
|
|
435
|
+
}
|
|
436
|
+
interface FilterEngagementEncoded {
|
|
437
|
+
readonly _tag: "Engagement";
|
|
438
|
+
readonly minLikes?: number;
|
|
439
|
+
readonly minReposts?: number;
|
|
440
|
+
readonly minReplies?: number;
|
|
441
|
+
}
|
|
442
|
+
interface FilterHasImagesEncoded {
|
|
443
|
+
readonly _tag: "HasImages";
|
|
444
|
+
}
|
|
445
|
+
interface FilterHasVideoEncoded {
|
|
446
|
+
readonly _tag: "HasVideo";
|
|
447
|
+
}
|
|
448
|
+
interface FilterHasLinksEncoded {
|
|
449
|
+
readonly _tag: "HasLinks";
|
|
450
|
+
}
|
|
451
|
+
interface FilterHasMediaEncoded {
|
|
452
|
+
readonly _tag: "HasMedia";
|
|
453
|
+
}
|
|
454
|
+
interface FilterHasEmbedEncoded {
|
|
455
|
+
readonly _tag: "HasEmbed";
|
|
456
|
+
}
|
|
457
|
+
interface FilterLanguageEncoded {
|
|
458
|
+
readonly _tag: "Language";
|
|
459
|
+
readonly langs: ReadonlyArray<string>;
|
|
460
|
+
}
|
|
461
|
+
type RegexPatternsEncoded = string | ReadonlyArray<string>;
|
|
462
|
+
interface FilterRegexEncoded {
|
|
463
|
+
readonly _tag: "Regex";
|
|
464
|
+
readonly patterns: RegexPatternsEncoded;
|
|
465
|
+
readonly flags?: string;
|
|
466
|
+
}
|
|
467
|
+
interface FilterDateRangeEncoded {
|
|
468
|
+
readonly _tag: "DateRange";
|
|
469
|
+
readonly start: string | Date;
|
|
470
|
+
readonly end: string | Date;
|
|
471
|
+
}
|
|
472
|
+
type FilterErrorPolicyEncoded = typeof FilterErrorPolicy.Encoded;
|
|
473
|
+
interface FilterHasValidLinksEncoded {
|
|
474
|
+
readonly _tag: "HasValidLinks";
|
|
475
|
+
readonly onError: FilterErrorPolicyEncoded;
|
|
476
|
+
}
|
|
477
|
+
interface FilterTrendingEncoded {
|
|
478
|
+
readonly _tag: "Trending";
|
|
479
|
+
readonly tag: string;
|
|
480
|
+
readonly onError: FilterErrorPolicyEncoded;
|
|
481
|
+
}
|
|
482
|
+
type FilterExprEncoded =
|
|
483
|
+
| FilterAllEncoded
|
|
484
|
+
| FilterNoneEncoded
|
|
485
|
+
| FilterAndEncoded
|
|
486
|
+
| FilterOrEncoded
|
|
487
|
+
| FilterNotEncoded
|
|
488
|
+
| FilterAuthorEncoded
|
|
489
|
+
| FilterHashtagEncoded
|
|
490
|
+
| FilterAuthorInEncoded
|
|
491
|
+
| FilterHashtagInEncoded
|
|
492
|
+
| FilterContainsEncoded
|
|
493
|
+
| FilterIsReplyEncoded
|
|
494
|
+
| FilterIsQuoteEncoded
|
|
495
|
+
| FilterIsRepostEncoded
|
|
496
|
+
| FilterIsOriginalEncoded
|
|
497
|
+
| FilterEngagementEncoded
|
|
498
|
+
| FilterHasImagesEncoded
|
|
499
|
+
| FilterHasVideoEncoded
|
|
500
|
+
| FilterHasLinksEncoded
|
|
501
|
+
| FilterHasMediaEncoded
|
|
502
|
+
| FilterHasEmbedEncoded
|
|
503
|
+
| FilterLanguageEncoded
|
|
504
|
+
| FilterRegexEncoded
|
|
505
|
+
| FilterDateRangeEncoded
|
|
506
|
+
| FilterHasValidLinksEncoded
|
|
507
|
+
| FilterTrendingEncoded;
|
|
508
|
+
|
|
509
|
+
/** JSON schema for serializing/deserializing filter expressions. */
|
|
510
|
+
export const FilterExprSchema: Schema.Schema<FilterExpr, FilterExprEncoded, never> = Schema.suspend(
|
|
511
|
+
() => FilterExprInternal
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const FilterAllSchema: Schema.Schema<FilterAll, FilterAllEncoded, never> = Schema.TaggedStruct(
|
|
515
|
+
"All",
|
|
516
|
+
{}
|
|
517
|
+
);
|
|
518
|
+
const FilterNoneSchema: Schema.Schema<FilterNone, FilterNoneEncoded, never> = Schema.TaggedStruct(
|
|
519
|
+
"None",
|
|
520
|
+
{}
|
|
521
|
+
);
|
|
522
|
+
const FilterAndSchema: Schema.Schema<FilterAnd, FilterAndEncoded, never> = Schema.TaggedStruct("And", {
|
|
523
|
+
left: required(FilterExprSchema, "\"left\" is required"),
|
|
524
|
+
right: required(FilterExprSchema, "\"right\" is required")
|
|
525
|
+
});
|
|
526
|
+
const FilterOrSchema: Schema.Schema<FilterOr, FilterOrEncoded, never> = Schema.TaggedStruct("Or", {
|
|
527
|
+
left: required(FilterExprSchema, "\"left\" is required"),
|
|
528
|
+
right: required(FilterExprSchema, "\"right\" is required")
|
|
529
|
+
});
|
|
530
|
+
const FilterNotSchema: Schema.Schema<FilterNot, FilterNotEncoded, never> = Schema.TaggedStruct("Not", {
|
|
531
|
+
expr: required(FilterExprSchema, "\"expr\" is required")
|
|
532
|
+
});
|
|
533
|
+
const FilterAuthorSchema: Schema.Schema<FilterAuthor, FilterAuthorEncoded, never> = Schema.TaggedStruct(
|
|
534
|
+
"Author",
|
|
535
|
+
{ handle: required(Handle, "\"handle\" is required") }
|
|
536
|
+
);
|
|
537
|
+
const FilterHashtagSchema: Schema.Schema<FilterHashtag, FilterHashtagEncoded, never> = Schema.TaggedStruct(
|
|
538
|
+
"Hashtag",
|
|
539
|
+
{ tag: required(Hashtag, "\"tag\" is required") }
|
|
540
|
+
);
|
|
541
|
+
const HandleList = Schema.Array(Handle).pipe(Schema.minItems(1));
|
|
542
|
+
const HashtagList = Schema.Array(Hashtag).pipe(Schema.minItems(1));
|
|
543
|
+
const FilterAuthorInSchema: Schema.Schema<FilterAuthorIn, FilterAuthorInEncoded, never> = Schema.TaggedStruct(
|
|
544
|
+
"AuthorIn",
|
|
545
|
+
{ handles: required(HandleList, "\"handles\" is required") }
|
|
546
|
+
);
|
|
547
|
+
const FilterHashtagInSchema: Schema.Schema<FilterHashtagIn, FilterHashtagInEncoded, never> = Schema.TaggedStruct(
|
|
548
|
+
"HashtagIn",
|
|
549
|
+
{ tags: required(HashtagList, "\"tags\" is required") }
|
|
550
|
+
);
|
|
551
|
+
const FilterContainsSchema: Schema.Schema<FilterContains, FilterContainsEncoded, never> = Schema.TaggedStruct(
|
|
552
|
+
"Contains",
|
|
553
|
+
{
|
|
554
|
+
text: required(Schema.NonEmptyString, "\"text\" is required"),
|
|
555
|
+
caseSensitive: Schema.optionalWith(Schema.Boolean, { exact: true })
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
const FilterIsReplySchema: Schema.Schema<FilterIsReply, FilterIsReplyEncoded, never> = Schema.TaggedStruct(
|
|
559
|
+
"IsReply",
|
|
560
|
+
{}
|
|
561
|
+
);
|
|
562
|
+
const FilterIsQuoteSchema: Schema.Schema<FilterIsQuote, FilterIsQuoteEncoded, never> = Schema.TaggedStruct(
|
|
563
|
+
"IsQuote",
|
|
564
|
+
{}
|
|
565
|
+
);
|
|
566
|
+
const FilterIsRepostSchema: Schema.Schema<FilterIsRepost, FilterIsRepostEncoded, never> = Schema.TaggedStruct(
|
|
567
|
+
"IsRepost",
|
|
568
|
+
{}
|
|
569
|
+
);
|
|
570
|
+
const FilterIsOriginalSchema: Schema.Schema<FilterIsOriginal, FilterIsOriginalEncoded, never> =
|
|
571
|
+
Schema.TaggedStruct("IsOriginal", {});
|
|
572
|
+
const EngagementThreshold = Schema.NonNegativeInt;
|
|
573
|
+
const FilterEngagementSchema: Schema.Schema<FilterEngagement, FilterEngagementEncoded, never> =
|
|
574
|
+
Schema.TaggedStruct("Engagement", {
|
|
575
|
+
minLikes: Schema.optionalWith(EngagementThreshold, { exact: true }),
|
|
576
|
+
minReposts: Schema.optionalWith(EngagementThreshold, { exact: true }),
|
|
577
|
+
minReplies: Schema.optionalWith(EngagementThreshold, { exact: true })
|
|
578
|
+
}).pipe(
|
|
579
|
+
Schema.filter((e) =>
|
|
580
|
+
e.minLikes !== undefined || e.minReposts !== undefined || e.minReplies !== undefined
|
|
581
|
+
? undefined
|
|
582
|
+
: "Engagement filter requires at least one threshold (minLikes, minReposts, or minReplies)"
|
|
583
|
+
)
|
|
584
|
+
) as any;
|
|
585
|
+
const FilterHasImagesSchema: Schema.Schema<FilterHasImages, FilterHasImagesEncoded, never> =
|
|
586
|
+
Schema.TaggedStruct("HasImages", {});
|
|
587
|
+
const FilterHasVideoSchema: Schema.Schema<FilterHasVideo, FilterHasVideoEncoded, never> = Schema.TaggedStruct(
|
|
588
|
+
"HasVideo",
|
|
589
|
+
{}
|
|
590
|
+
);
|
|
591
|
+
const FilterHasLinksSchema: Schema.Schema<FilterHasLinks, FilterHasLinksEncoded, never> = Schema.TaggedStruct(
|
|
592
|
+
"HasLinks",
|
|
593
|
+
{}
|
|
594
|
+
);
|
|
595
|
+
const FilterHasMediaSchema: Schema.Schema<FilterHasMedia, FilterHasMediaEncoded, never> = Schema.TaggedStruct(
|
|
596
|
+
"HasMedia",
|
|
597
|
+
{}
|
|
598
|
+
);
|
|
599
|
+
const FilterHasEmbedSchema: Schema.Schema<FilterHasEmbed, FilterHasEmbedEncoded, never> = Schema.TaggedStruct(
|
|
600
|
+
"HasEmbed",
|
|
601
|
+
{}
|
|
602
|
+
);
|
|
603
|
+
const LanguageList = Schema.Array(Schema.NonEmptyString).pipe(Schema.minItems(1));
|
|
604
|
+
const FilterLanguageSchema: Schema.Schema<FilterLanguage, FilterLanguageEncoded, never> = Schema.TaggedStruct(
|
|
605
|
+
"Language",
|
|
606
|
+
{ langs: required(LanguageList, "\"langs\" is required") }
|
|
607
|
+
);
|
|
608
|
+
const RegexPattern = Schema.NonEmptyString;
|
|
609
|
+
const RegexPatternList = Schema.Array(RegexPattern).pipe(Schema.minItems(1));
|
|
610
|
+
const RegexPatternsSchema: Schema.Schema<
|
|
611
|
+
ReadonlyArray<string>,
|
|
612
|
+
RegexPatternsEncoded,
|
|
613
|
+
never
|
|
614
|
+
> = Schema.transform(
|
|
615
|
+
Schema.Union(RegexPattern, RegexPatternList),
|
|
616
|
+
RegexPatternList,
|
|
617
|
+
{
|
|
618
|
+
strict: true,
|
|
619
|
+
decode: (input, _fromInput) =>
|
|
620
|
+
Array.isArray(input) ? input : [input],
|
|
621
|
+
encode: (_patternsInput, patterns) =>
|
|
622
|
+
patterns.length === 1 ? patterns[0]! : patterns
|
|
623
|
+
}
|
|
624
|
+
);
|
|
625
|
+
const FilterRegexSchema: Schema.Schema<FilterRegex, FilterRegexEncoded, never> = Schema.TaggedStruct(
|
|
626
|
+
"Regex",
|
|
627
|
+
{
|
|
628
|
+
patterns: required(RegexPatternsSchema, "\"patterns\" is required"),
|
|
629
|
+
flags: Schema.optionalWith(Schema.String, { exact: true })
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
const FilterDateRangeSchema: Schema.Schema<
|
|
633
|
+
FilterDateRange,
|
|
634
|
+
FilterDateRangeEncoded,
|
|
635
|
+
never
|
|
636
|
+
> = Schema.TaggedStruct("DateRange", {
|
|
637
|
+
start: required(Timestamp, "\"start\" is required"),
|
|
638
|
+
end: required(Timestamp, "\"end\" is required")
|
|
639
|
+
}).pipe(
|
|
640
|
+
Schema.filter((dr) =>
|
|
641
|
+
dr.start.getTime() < dr.end.getTime()
|
|
642
|
+
? undefined
|
|
643
|
+
: "\"start\" must be before \"end\""
|
|
644
|
+
)
|
|
645
|
+
) as any;
|
|
646
|
+
const FilterHasValidLinksSchema: Schema.Schema<
|
|
647
|
+
FilterHasValidLinks,
|
|
648
|
+
FilterHasValidLinksEncoded,
|
|
649
|
+
never
|
|
650
|
+
> = Schema.TaggedStruct("HasValidLinks", {
|
|
651
|
+
onError: required(FilterErrorPolicy, "\"onError\" is required")
|
|
652
|
+
});
|
|
653
|
+
const FilterTrendingSchema: Schema.Schema<FilterTrending, FilterTrendingEncoded, never> = Schema.TaggedStruct(
|
|
654
|
+
"Trending",
|
|
655
|
+
{
|
|
656
|
+
tag: required(Hashtag, "\"tag\" is required"),
|
|
657
|
+
onError: required(FilterErrorPolicy, "\"onError\" is required")
|
|
658
|
+
}
|
|
659
|
+
);
|
|
660
|
+
const FilterExprInternal: Schema.Schema<FilterExpr, FilterExprEncoded, never> = Schema.Union(
|
|
661
|
+
FilterAllSchema,
|
|
662
|
+
FilterNoneSchema,
|
|
663
|
+
FilterAndSchema,
|
|
664
|
+
FilterOrSchema,
|
|
665
|
+
FilterNotSchema,
|
|
666
|
+
FilterAuthorSchema,
|
|
667
|
+
FilterHashtagSchema,
|
|
668
|
+
FilterAuthorInSchema,
|
|
669
|
+
FilterHashtagInSchema,
|
|
670
|
+
FilterContainsSchema,
|
|
671
|
+
FilterIsReplySchema,
|
|
672
|
+
FilterIsQuoteSchema,
|
|
673
|
+
FilterIsRepostSchema,
|
|
674
|
+
FilterIsOriginalSchema,
|
|
675
|
+
FilterEngagementSchema,
|
|
676
|
+
FilterHasImagesSchema,
|
|
677
|
+
FilterHasVideoSchema,
|
|
678
|
+
FilterHasLinksSchema,
|
|
679
|
+
FilterHasMediaSchema,
|
|
680
|
+
FilterHasEmbedSchema,
|
|
681
|
+
FilterLanguageSchema,
|
|
682
|
+
FilterRegexSchema,
|
|
683
|
+
FilterDateRangeSchema,
|
|
684
|
+
FilterHasValidLinksSchema,
|
|
685
|
+
FilterTrendingSchema
|
|
686
|
+
).annotations({ identifier: "FilterExpr" });
|
|
687
|
+
|
|
688
|
+
/** Creates a filter that matches all posts. */
|
|
689
|
+
export const all = (): FilterAll => ({ _tag: "All" });
|
|
690
|
+
|
|
691
|
+
/** Creates a filter that matches no posts. */
|
|
692
|
+
export const none = (): FilterNone => ({ _tag: "None" });
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Creates an AND filter combining two expressions.
|
|
696
|
+
* @param left - First filter expression
|
|
697
|
+
* @param right - Second filter expression
|
|
698
|
+
* @returns A filter that matches when both expressions match
|
|
699
|
+
*/
|
|
700
|
+
export const and = (left: FilterExpr, right: FilterExpr): FilterAnd => ({
|
|
701
|
+
_tag: "And",
|
|
702
|
+
left,
|
|
703
|
+
right
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Creates an OR filter combining two expressions.
|
|
708
|
+
* @param left - First filter expression
|
|
709
|
+
* @param right - Second filter expression
|
|
710
|
+
* @returns A filter that matches when either expression matches
|
|
711
|
+
*/
|
|
712
|
+
export const or = (left: FilterExpr, right: FilterExpr): FilterOr => ({
|
|
713
|
+
_tag: "Or",
|
|
714
|
+
left,
|
|
715
|
+
right
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Creates a NOT filter that negates an expression.
|
|
720
|
+
* @param expr - The filter expression to negate
|
|
721
|
+
* @returns A filter that matches when the expression does NOT match
|
|
722
|
+
*/
|
|
723
|
+
export const not = (expr: FilterExpr): FilterNot => ({ _tag: "Not", expr });
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Semigroup for combining filters using logical AND.
|
|
727
|
+
*
|
|
728
|
+
* Enables combining multiple filters: `filters.reduce(FilterExprSemigroup.combine)`
|
|
729
|
+
*/
|
|
730
|
+
export const FilterExprSemigroup: Semigroup.Semigroup<FilterExpr> = Semigroup.make(
|
|
731
|
+
(left, right) => and(left, right)
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Monoid for filters with `all()` as the identity element.
|
|
736
|
+
*
|
|
737
|
+
* Provides `combine` (AND) and `empty` (match all) operations.
|
|
738
|
+
*/
|
|
739
|
+
export const FilterExprMonoid: Monoid.Monoid<FilterExpr> = Monoid.fromSemigroup(
|
|
740
|
+
FilterExprSemigroup,
|
|
741
|
+
all()
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Encodes a filter expression to its JSON-serializable form.
|
|
746
|
+
*
|
|
747
|
+
* @param expr - The filter expression to encode
|
|
748
|
+
* @returns The encoded filter expression
|
|
749
|
+
*/
|
|
750
|
+
export const encodeFilterExpr = (expr: FilterExpr): FilterExprEncoded =>
|
|
751
|
+
Schema.encodeSync(FilterExprSchema)(expr);
|
|
752
|
+
|
|
753
|
+
const canonicalJson = (value: unknown): string => {
|
|
754
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
755
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
756
|
+
const sorted = Object.keys(value as Record<string, unknown>).sort();
|
|
757
|
+
const entries = sorted.map(
|
|
758
|
+
(k) => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`
|
|
759
|
+
);
|
|
760
|
+
return `{${entries.join(",")}}`;
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Generates a canonical signature for a filter expression.
|
|
765
|
+
*
|
|
766
|
+
* This produces a consistent string representation that can be used for
|
|
767
|
+
* caching, comparison, or generating stable identifiers.
|
|
768
|
+
*
|
|
769
|
+
* @param expr - The filter expression
|
|
770
|
+
* @returns A canonical JSON string representation
|
|
771
|
+
*/
|
|
772
|
+
export const filterExprSignature = (expr: FilterExpr): string =>
|
|
773
|
+
canonicalJson(encodeFilterExpr(expr));
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Checks if a filter expression requires effects to evaluate.
|
|
777
|
+
*
|
|
778
|
+
* Effectful filters (like `HasValidLinks` and `Trending`) may perform
|
|
779
|
+
* async operations like HTTP requests and need special handling.
|
|
780
|
+
*
|
|
781
|
+
* @param expr - The filter expression to check
|
|
782
|
+
* @returns True if the filter requires effects to evaluate
|
|
783
|
+
*/
|
|
784
|
+
export const isEffectfulFilter = (expr: FilterExpr): boolean => {
|
|
785
|
+
switch (expr._tag) {
|
|
786
|
+
case "HasValidLinks":
|
|
787
|
+
case "Trending":
|
|
788
|
+
return true;
|
|
789
|
+
case "And":
|
|
790
|
+
case "Or":
|
|
791
|
+
return isEffectfulFilter(expr.left) || isEffectfulFilter(expr.right);
|
|
792
|
+
case "Not":
|
|
793
|
+
return isEffectfulFilter(expr.expr);
|
|
794
|
+
default:
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
};
|