@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.
Files changed (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. 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
+ };