@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,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter Compiler Service
|
|
3
|
+
*
|
|
4
|
+
* Validates and compiles filter specifications for Bluesky post filtering.
|
|
5
|
+
* Ensures filter expressions are well-formed before they are stored or used.
|
|
6
|
+
*
|
|
7
|
+
* **Validation includes:**
|
|
8
|
+
* - Regex pattern syntax validation
|
|
9
|
+
* - Required field presence (e.g., handles in AuthorIn, tags in HashtagIn)
|
|
10
|
+
* - Logical constraints (e.g., DateRange start before end)
|
|
11
|
+
* - Policy validation (e.g., Retry parameters)
|
|
12
|
+
* - Nested expression validation (And, Or, Not combinators)
|
|
13
|
+
*
|
|
14
|
+
* The compiler performs structural validation without executing filters
|
|
15
|
+
* against actual data. Use the compiled filter expressions with the
|
|
16
|
+
* FilterEvaluator for runtime filtering.
|
|
17
|
+
*
|
|
18
|
+
* **Error Handling:**
|
|
19
|
+
* Returns FilterCompileError with descriptive messages for any validation
|
|
20
|
+
* failures, enabling CLI-friendly error reporting.
|
|
21
|
+
*
|
|
22
|
+
* @module services/filter-compiler
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* import { FilterCompiler } from "./services/filter-compiler.js";
|
|
27
|
+
* import { Effect } from "effect";
|
|
28
|
+
*
|
|
29
|
+
* const program = Effect.gen(function* () {
|
|
30
|
+
* const compiler = yield* FilterCompiler;
|
|
31
|
+
*
|
|
32
|
+
* // Compile a filter spec
|
|
33
|
+
* const filterExpr = yield* compiler.compile({
|
|
34
|
+
* expr: { _tag: "Contains", text: "bluesky" }
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* // Or validate an existing expression
|
|
38
|
+
* yield* compiler.validate(filterExpr);
|
|
39
|
+
* }).pipe(Effect.provide(FilterCompiler.layer));
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Context, Duration, Effect, Layer } from "effect";
|
|
44
|
+
import { FilterCompileError } from "../domain/errors.js";
|
|
45
|
+
import type { FilterExpr } from "../domain/filter.js";
|
|
46
|
+
import type { FilterErrorPolicy } from "../domain/policies.js";
|
|
47
|
+
import type { FilterSpec } from "../domain/store.js";
|
|
48
|
+
|
|
49
|
+
const invalid = (message: string) => FilterCompileError.make({ message });
|
|
50
|
+
|
|
51
|
+
const messageFromError = (error: unknown) => {
|
|
52
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
53
|
+
const message = (error as { readonly message?: unknown }).message;
|
|
54
|
+
if (typeof message === "string") return message;
|
|
55
|
+
}
|
|
56
|
+
return String(error);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const validatePolicy: (policy: FilterErrorPolicy) => Effect.Effect<void, FilterCompileError> =
|
|
60
|
+
Effect.fn("FilterCompiler.validatePolicy")((policy: FilterErrorPolicy) => {
|
|
61
|
+
switch (policy._tag) {
|
|
62
|
+
case "Include":
|
|
63
|
+
case "Exclude":
|
|
64
|
+
return Effect.void;
|
|
65
|
+
case "Retry":
|
|
66
|
+
return Effect.gen(function* () {
|
|
67
|
+
if (!Number.isInteger(policy.maxRetries) || policy.maxRetries < 0) {
|
|
68
|
+
return yield* invalid(
|
|
69
|
+
`Retry maxRetries must be a non-negative integer (got ${policy.maxRetries})`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!Duration.isFinite(policy.baseDelay)) {
|
|
73
|
+
return yield* invalid(
|
|
74
|
+
"Retry baseDelay must be a finite duration"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const validateRegex = (patterns: ReadonlyArray<string>, flags?: string) =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
if (patterns.length === 0) {
|
|
85
|
+
return yield* invalid("Regex patterns must contain at least one entry");
|
|
86
|
+
}
|
|
87
|
+
yield* Effect.forEach(
|
|
88
|
+
patterns,
|
|
89
|
+
(pattern) =>
|
|
90
|
+
Effect.try({
|
|
91
|
+
try: () => {
|
|
92
|
+
RegExp(pattern, flags);
|
|
93
|
+
},
|
|
94
|
+
catch: (error) =>
|
|
95
|
+
invalid(`Invalid regex "${pattern}": ${messageFromError(error)}`)
|
|
96
|
+
}),
|
|
97
|
+
{ discard: true }
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const validateExpr: (expr: FilterExpr) => Effect.Effect<void, FilterCompileError> =
|
|
102
|
+
Effect.fn("FilterCompiler.validateExpr")(function* (expr: FilterExpr) {
|
|
103
|
+
switch (expr._tag) {
|
|
104
|
+
case "All":
|
|
105
|
+
case "None":
|
|
106
|
+
case "Author":
|
|
107
|
+
case "Hashtag":
|
|
108
|
+
return;
|
|
109
|
+
case "AuthorIn":
|
|
110
|
+
if (expr.handles.length === 0) {
|
|
111
|
+
return yield* invalid("AuthorIn handles must contain at least one entry");
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
case "HashtagIn":
|
|
115
|
+
if (expr.tags.length === 0) {
|
|
116
|
+
return yield* invalid("HashtagIn tags must contain at least one entry");
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
case "Contains":
|
|
120
|
+
if (expr.text.trim().length === 0) {
|
|
121
|
+
return yield* invalid("Contains text must be non-empty");
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
case "IsReply":
|
|
125
|
+
case "IsQuote":
|
|
126
|
+
case "IsRepost":
|
|
127
|
+
case "IsOriginal":
|
|
128
|
+
case "HasImages":
|
|
129
|
+
case "HasVideo":
|
|
130
|
+
case "HasLinks":
|
|
131
|
+
case "HasMedia":
|
|
132
|
+
case "HasEmbed":
|
|
133
|
+
return;
|
|
134
|
+
case "Language":
|
|
135
|
+
if (expr.langs.length === 0) {
|
|
136
|
+
return yield* invalid("Language langs must contain at least one entry");
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
case "Engagement":
|
|
140
|
+
if (
|
|
141
|
+
expr.minLikes === undefined &&
|
|
142
|
+
expr.minReposts === undefined &&
|
|
143
|
+
expr.minReplies === undefined
|
|
144
|
+
) {
|
|
145
|
+
return yield* invalid(
|
|
146
|
+
"Engagement requires at least one threshold (minLikes, minReposts, minReplies)"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
case "Regex":
|
|
151
|
+
return yield* validateRegex(expr.patterns, expr.flags);
|
|
152
|
+
case "DateRange":
|
|
153
|
+
if (expr.start.getTime() >= expr.end.getTime()) {
|
|
154
|
+
return yield* invalid("DateRange start must be before end");
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
case "And":
|
|
158
|
+
yield* validateExpr(expr.left);
|
|
159
|
+
return yield* validateExpr(expr.right);
|
|
160
|
+
case "Or":
|
|
161
|
+
yield* validateExpr(expr.left);
|
|
162
|
+
return yield* validateExpr(expr.right);
|
|
163
|
+
case "Not":
|
|
164
|
+
return yield* validateExpr(expr.expr);
|
|
165
|
+
case "HasValidLinks":
|
|
166
|
+
return yield* validatePolicy(expr.onError);
|
|
167
|
+
case "Trending":
|
|
168
|
+
return yield* validatePolicy(expr.onError);
|
|
169
|
+
default:
|
|
170
|
+
return yield* invalid(
|
|
171
|
+
`Unknown filter tag: ${(expr as { _tag: string })._tag}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Context tag and Layer implementation for the filter compiler service.
|
|
178
|
+
* Provides compile-time validation of filter expressions.
|
|
179
|
+
*
|
|
180
|
+
* **Supported Filter Types:**
|
|
181
|
+
* - Basic: All, None, Author, Hashtag, Contains, IsReply, IsQuote, IsRepost
|
|
182
|
+
* - Collections: AuthorIn, HashtagIn (require non-empty arrays)
|
|
183
|
+
* - Media: HasImages, HasVideo, HasLinks, HasMedia, HasEmbed
|
|
184
|
+
* - Metadata: Language (requires langs array), Engagement (requires at least one threshold)
|
|
185
|
+
* - Time: DateRange (start must be before end)
|
|
186
|
+
* - Text: Regex (validates pattern syntax)
|
|
187
|
+
* - Combinators: And, Or, Not (recursively validates children)
|
|
188
|
+
* - Async: HasValidLinks, Trending (validates error policy)
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* // Basic compilation
|
|
193
|
+
* const spec = { expr: { _tag: "Author", handle: "user.bsky.social" } };
|
|
194
|
+
* const compiled = yield* compiler.compile(spec);
|
|
195
|
+
*
|
|
196
|
+
* // Regex validation
|
|
197
|
+
* const regexFilter = {
|
|
198
|
+
* expr: { _tag: "Regex", patterns: ["^test$", "[invalid"], flags: "i" }
|
|
199
|
+
* };
|
|
200
|
+
* // Fails with: Invalid regex "[invalid": Invalid regular expression
|
|
201
|
+
*
|
|
202
|
+
* // Combinator validation
|
|
203
|
+
* const complexFilter = {
|
|
204
|
+
* expr: {
|
|
205
|
+
* _tag: "And",
|
|
206
|
+
* left: { _tag: "HasImages" },
|
|
207
|
+
* right: {
|
|
208
|
+
* _tag: "Or",
|
|
209
|
+
* left: { _tag: "Contains", text: "photo" },
|
|
210
|
+
* right: { _tag: "Contains", text: "picture" }
|
|
211
|
+
* }
|
|
212
|
+
* }
|
|
213
|
+
* };
|
|
214
|
+
* yield* compiler.compile(complexFilter); // OK
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export class FilterCompiler extends Context.Tag("@skygent/FilterCompiler")<
|
|
218
|
+
FilterCompiler,
|
|
219
|
+
{
|
|
220
|
+
/**
|
|
221
|
+
* Compiles a filter spec by validating its expression.
|
|
222
|
+
* Returns the original expression if valid.
|
|
223
|
+
*
|
|
224
|
+
* @param spec - The filter specification containing the expression to validate
|
|
225
|
+
* @returns Effect resolving to the validated FilterExpr
|
|
226
|
+
* @throws {FilterCompileError} When validation fails with detailed message
|
|
227
|
+
*/
|
|
228
|
+
readonly compile: (spec: FilterSpec) => Effect.Effect<FilterExpr, FilterCompileError>;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Validates a filter expression without a spec wrapper.
|
|
232
|
+
* Useful for re-validating expressions or standalone validation.
|
|
233
|
+
*
|
|
234
|
+
* @param expr - The filter expression to validate
|
|
235
|
+
* @returns Effect resolving to void on success
|
|
236
|
+
* @throws {FilterCompileError} When validation fails
|
|
237
|
+
*/
|
|
238
|
+
readonly validate: (expr: FilterExpr) => Effect.Effect<void, FilterCompileError>;
|
|
239
|
+
}
|
|
240
|
+
>() {
|
|
241
|
+
/**
|
|
242
|
+
* Layer that provides the filter compiler service.
|
|
243
|
+
* Stateless service with no dependencies.
|
|
244
|
+
*/
|
|
245
|
+
static readonly layer = Layer.succeed(
|
|
246
|
+
FilterCompiler,
|
|
247
|
+
FilterCompiler.of({
|
|
248
|
+
/**
|
|
249
|
+
* Compiles a filter specification by validating its expression.
|
|
250
|
+
* Returns the expression unchanged if valid.
|
|
251
|
+
*
|
|
252
|
+
* @param spec - Filter specification with expr field
|
|
253
|
+
* @returns Validated filter expression
|
|
254
|
+
*/
|
|
255
|
+
compile: Effect.fn("FilterCompiler.compile")((spec: FilterSpec) =>
|
|
256
|
+
validateExpr(spec.expr).pipe(Effect.as(spec.expr))
|
|
257
|
+
),
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Validates a filter expression recursively.
|
|
261
|
+
* Checks all constraints based on expression type.
|
|
262
|
+
*
|
|
263
|
+
* @param expr - Filter expression to validate
|
|
264
|
+
* @returns Effect that succeeds if valid, fails with FilterCompileError otherwise
|
|
265
|
+
*/
|
|
266
|
+
validate: Effect.fn("FilterCompiler.validate")(validateExpr)
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter Library Service
|
|
3
|
+
*
|
|
4
|
+
* Manages a library of saved filter expressions stored as JSON files.
|
|
5
|
+
* Filters are persisted in the `~/.skygent/filters/` directory, with each
|
|
6
|
+
* filter saved as a separate `.json` file named after the filter.
|
|
7
|
+
*
|
|
8
|
+
* This service enables users to create reusable filter expressions that can
|
|
9
|
+
* be referenced by name in commands and other operations. Filters are validated
|
|
10
|
+
* against the FilterExprSchema to ensure they conform to the expected structure.
|
|
11
|
+
*
|
|
12
|
+
* @module services/filter-library
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { Effect } from "effect";
|
|
16
|
+
* import { FilterLibrary } from "./services/filter-library.js";
|
|
17
|
+
* import { StoreName } from "./domain/primitives.js";
|
|
18
|
+
* import type { FilterExpr } from "./domain/filter.js";
|
|
19
|
+
*
|
|
20
|
+
* const program = Effect.gen(function* () {
|
|
21
|
+
* const library = yield* FilterLibrary;
|
|
22
|
+
*
|
|
23
|
+
* // List all saved filters
|
|
24
|
+
* const filters = yield* library.list();
|
|
25
|
+
* console.log("Available filters:", filters);
|
|
26
|
+
*
|
|
27
|
+
* // Save a filter expression
|
|
28
|
+
* const myFilter: FilterExpr = { and: [{ hasText: "hello" }] };
|
|
29
|
+
* yield* library.save(StoreName.make("greetings"), myFilter);
|
|
30
|
+
*
|
|
31
|
+
* // Retrieve a saved filter
|
|
32
|
+
* const retrieved = yield* library.get(StoreName.make("greetings"));
|
|
33
|
+
*
|
|
34
|
+
* // Validate all saved filters
|
|
35
|
+
* const validationResults = yield* library.validateAll();
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { FileSystem, Path } from "@effect/platform";
|
|
41
|
+
import { formatSchemaError } from "./shared.js";
|
|
42
|
+
import type { PlatformError } from "@effect/platform/Error";
|
|
43
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
44
|
+
import { FilterExprSchema } from "../domain/filter.js";
|
|
45
|
+
import type { FilterExpr } from "../domain/filter.js";
|
|
46
|
+
import { FilterLibraryError, FilterNotFound } from "../domain/errors.js";
|
|
47
|
+
import { StoreName } from "../domain/primitives.js";
|
|
48
|
+
import { AppConfigService } from "./app-config.js";
|
|
49
|
+
|
|
50
|
+
/** Directory name for storing filter JSON files */
|
|
51
|
+
const filtersDirName = "filters";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Ensures a string ends with a newline character.
|
|
55
|
+
* Used for consistent file formatting when saving JSON files.
|
|
56
|
+
* @param value - The string to ensure has a trailing newline
|
|
57
|
+
* @returns The string with a trailing newline if it didn't have one
|
|
58
|
+
*/
|
|
59
|
+
const ensureTrailingNewline = (value: string) =>
|
|
60
|
+
value.endsWith("\n") ? value : `${value}\n`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a FilterLibraryError with contextual information.
|
|
64
|
+
* @param message - Human-readable error message
|
|
65
|
+
* @param name - Optional filter name associated with the error
|
|
66
|
+
* @param path - Optional file path associated with the error
|
|
67
|
+
* @param cause - Optional underlying cause of the error
|
|
68
|
+
* @returns A FilterLibraryError instance
|
|
69
|
+
*/
|
|
70
|
+
const toLibraryError = (
|
|
71
|
+
message: string,
|
|
72
|
+
name?: string,
|
|
73
|
+
path?: string,
|
|
74
|
+
cause?: unknown
|
|
75
|
+
) =>
|
|
76
|
+
FilterLibraryError.make({
|
|
77
|
+
message,
|
|
78
|
+
name,
|
|
79
|
+
path,
|
|
80
|
+
cause
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Encodes a filter expression to JSON string.
|
|
85
|
+
* Validates the expression against FilterExprSchema during encoding.
|
|
86
|
+
* @param expr - The filter expression to encode
|
|
87
|
+
* @returns JSON string representation of the filter
|
|
88
|
+
*/
|
|
89
|
+
const encodeFilterJson = (expr: FilterExpr) =>
|
|
90
|
+
Schema.encodeSync(Schema.parseJson(FilterExprSchema))(expr);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Decodes a JSON string to a filter expression.
|
|
94
|
+
* Validates the JSON against FilterExprSchema during decoding.
|
|
95
|
+
* @param raw - The JSON string to decode
|
|
96
|
+
* @returns An Effect that resolves to the decoded FilterExpr
|
|
97
|
+
*/
|
|
98
|
+
const decodeFilterJson = (raw: string) =>
|
|
99
|
+
Schema.decodeUnknown(Schema.parseJson(FilterExprSchema))(raw);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Constructs the file path for a filter's JSON file.
|
|
103
|
+
* @param path - Path utility from Effect platform
|
|
104
|
+
* @param root - Root directory for the application
|
|
105
|
+
* @param name - Name of the filter (becomes the filename)
|
|
106
|
+
* @returns Full path to the filter's JSON file
|
|
107
|
+
*/
|
|
108
|
+
const filterPath = (path: Path.Path, root: string, name: StoreName) =>
|
|
109
|
+
path.join(root, filtersDirName, `${name}.json`);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Effect Context Tag for the Filter Library service.
|
|
113
|
+
* Manages persistent storage of filter expressions as JSON files.
|
|
114
|
+
*
|
|
115
|
+
* Filters are stored in `~/.skygent/filters/` with each filter as a separate
|
|
116
|
+
* `.json` file. The filter name becomes the filename (e.g., `myfilter.json`).
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* // Use in an Effect program
|
|
121
|
+
* const program = Effect.gen(function* () {
|
|
122
|
+
* const library = yield* FilterLibrary;
|
|
123
|
+
*
|
|
124
|
+
* // Save a filter
|
|
125
|
+
* yield* library.save(StoreName.make("tech-posts"), {
|
|
126
|
+
* and: [
|
|
127
|
+
* { hasText: "javascript" },
|
|
128
|
+
* { or: [{ hasText: "typescript" }, { hasText: "node" }] }
|
|
129
|
+
* ]
|
|
130
|
+
* });
|
|
131
|
+
*
|
|
132
|
+
* // List all filters
|
|
133
|
+
* const filters = yield* library.list();
|
|
134
|
+
*
|
|
135
|
+
* // Load a filter
|
|
136
|
+
* const filter = yield* library.get(StoreName.make("tech-posts"));
|
|
137
|
+
* });
|
|
138
|
+
*
|
|
139
|
+
* // Provide the layer
|
|
140
|
+
* const runnable = program.pipe(Effect.provide(FilterLibrary.layer));
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export class FilterLibrary extends Context.Tag("@skygent/FilterLibrary")<
|
|
144
|
+
FilterLibrary,
|
|
145
|
+
{
|
|
146
|
+
/**
|
|
147
|
+
* Lists all saved filter names.
|
|
148
|
+
* Returns filter names without the `.json` extension, sorted alphabetically.
|
|
149
|
+
* Returns an empty array if the filters directory doesn't exist.
|
|
150
|
+
* @returns An Effect that resolves to an array of filter names
|
|
151
|
+
* @throws {FilterLibraryError} If listing filters fails
|
|
152
|
+
*/
|
|
153
|
+
readonly list: () => Effect.Effect<ReadonlyArray<string>, FilterLibraryError>;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Retrieves a saved filter expression by name.
|
|
157
|
+
* @param name - The name of the filter to retrieve
|
|
158
|
+
* @returns An Effect that resolves to the filter expression
|
|
159
|
+
* @throws {FilterNotFound} If the filter doesn't exist
|
|
160
|
+
* @throws {FilterLibraryError} If reading or parsing the filter fails
|
|
161
|
+
*/
|
|
162
|
+
readonly get: (
|
|
163
|
+
name: StoreName
|
|
164
|
+
) => Effect.Effect<
|
|
165
|
+
FilterExpr,
|
|
166
|
+
FilterNotFound | FilterLibraryError
|
|
167
|
+
>;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Saves a filter expression to the library.
|
|
171
|
+
* Creates the filters directory if it doesn't exist.
|
|
172
|
+
* Overwrites existing filters with the same name.
|
|
173
|
+
* @param name - The name for the filter
|
|
174
|
+
* @param expr - The filter expression to save
|
|
175
|
+
* @returns An Effect that resolves when the filter is saved
|
|
176
|
+
* @throws {FilterLibraryError} If saving fails
|
|
177
|
+
*/
|
|
178
|
+
readonly save: (
|
|
179
|
+
name: StoreName,
|
|
180
|
+
expr: FilterExpr
|
|
181
|
+
) => Effect.Effect<void, FilterLibraryError>;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Removes a filter from the library.
|
|
185
|
+
* @param name - The name of the filter to remove
|
|
186
|
+
* @returns An Effect that resolves when the filter is removed
|
|
187
|
+
* @throws {FilterNotFound} If the filter doesn't exist
|
|
188
|
+
* @throws {FilterLibraryError} If removing fails
|
|
189
|
+
*/
|
|
190
|
+
readonly remove: (
|
|
191
|
+
name: StoreName
|
|
192
|
+
) => Effect.Effect<void, FilterNotFound | FilterLibraryError>;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validates all saved filters.
|
|
196
|
+
* Checks each filter for proper JSON syntax and valid filter expression structure.
|
|
197
|
+
* Returns results for each filter indicating success or failure with error details.
|
|
198
|
+
* @returns An Effect that resolves to validation results for each filter
|
|
199
|
+
* @throws {FilterLibraryError} If the validation process itself fails
|
|
200
|
+
*/
|
|
201
|
+
readonly validateAll: () => Effect.Effect<
|
|
202
|
+
ReadonlyArray<{ readonly name: string; readonly ok: boolean; readonly error?: string }>,
|
|
203
|
+
FilterLibraryError
|
|
204
|
+
>;
|
|
205
|
+
}
|
|
206
|
+
>() {
|
|
207
|
+
/**
|
|
208
|
+
* Production layer that provides the FilterLibrary service.
|
|
209
|
+
* Requires FileSystem, Path, and AppConfigService to be provided.
|
|
210
|
+
*
|
|
211
|
+
* The implementation stores filters as JSON files in the filters directory,
|
|
212
|
+
* with each filter named `{filterName}.json`.
|
|
213
|
+
*/
|
|
214
|
+
static readonly layer = Layer.effect(
|
|
215
|
+
FilterLibrary,
|
|
216
|
+
Effect.gen(function* () {
|
|
217
|
+
const config = yield* AppConfigService;
|
|
218
|
+
const fs = yield* FileSystem.FileSystem;
|
|
219
|
+
const path = yield* Path.Path;
|
|
220
|
+
const rootDir = path.join(config.storeRoot, filtersDirName);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Lists all saved filters by reading the filters directory.
|
|
224
|
+
* Returns empty array if directory doesn't exist.
|
|
225
|
+
*/
|
|
226
|
+
const list = Effect.fn("FilterLibrary.list")(() =>
|
|
227
|
+
fs.readDirectory(rootDir).pipe(
|
|
228
|
+
Effect.catchTag("SystemError", (error) =>
|
|
229
|
+
error.reason === "NotFound" ? Effect.succeed([]) : Effect.fail(error)
|
|
230
|
+
),
|
|
231
|
+
Effect.map((entries) =>
|
|
232
|
+
entries
|
|
233
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
234
|
+
.map((entry) => entry.slice(0, -".json".length))
|
|
235
|
+
.sort()
|
|
236
|
+
),
|
|
237
|
+
Effect.mapError((error) =>
|
|
238
|
+
toLibraryError("Failed to list filters", undefined, rootDir, error)
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const get = Effect.fn("FilterLibrary.get")((name: StoreName) => {
|
|
244
|
+
const filePath = filterPath(path, config.storeRoot, name);
|
|
245
|
+
return fs.readFileString(filePath).pipe(
|
|
246
|
+
Effect.mapError((error: PlatformError) =>
|
|
247
|
+
error._tag === "SystemError" && error.reason === "NotFound"
|
|
248
|
+
? FilterNotFound.make({ name })
|
|
249
|
+
: toLibraryError(
|
|
250
|
+
`Failed to read filter "${name}"`,
|
|
251
|
+
name,
|
|
252
|
+
filePath,
|
|
253
|
+
error
|
|
254
|
+
)
|
|
255
|
+
),
|
|
256
|
+
Effect.flatMap((raw) =>
|
|
257
|
+
decodeFilterJson(raw).pipe(
|
|
258
|
+
Effect.mapError((error) =>
|
|
259
|
+
toLibraryError(
|
|
260
|
+
`Invalid filter JSON for "${name}": ${formatSchemaError(error)}`,
|
|
261
|
+
name,
|
|
262
|
+
filePath,
|
|
263
|
+
error
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const save = Effect.fn("FilterLibrary.save")(
|
|
272
|
+
(name: StoreName, expr: typeof FilterExprSchema.Type) =>
|
|
273
|
+
Effect.gen(function* () {
|
|
274
|
+
const filePath = filterPath(path, config.storeRoot, name);
|
|
275
|
+
yield* fs
|
|
276
|
+
.makeDirectory(rootDir, { recursive: true })
|
|
277
|
+
.pipe(
|
|
278
|
+
Effect.mapError((error) =>
|
|
279
|
+
toLibraryError(
|
|
280
|
+
`Failed to create filter directory`,
|
|
281
|
+
name,
|
|
282
|
+
rootDir,
|
|
283
|
+
error
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
const json = ensureTrailingNewline(encodeFilterJson(expr));
|
|
288
|
+
yield* fs
|
|
289
|
+
.writeFileString(filePath, json)
|
|
290
|
+
.pipe(
|
|
291
|
+
Effect.mapError((error) =>
|
|
292
|
+
toLibraryError(
|
|
293
|
+
`Failed to save filter "${name}"`,
|
|
294
|
+
name,
|
|
295
|
+
filePath,
|
|
296
|
+
error
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const remove = Effect.fn("FilterLibrary.remove")((name: StoreName) => {
|
|
304
|
+
const filePath = filterPath(path, config.storeRoot, name);
|
|
305
|
+
return fs.remove(filePath).pipe(
|
|
306
|
+
Effect.mapError((error: PlatformError) =>
|
|
307
|
+
error._tag === "SystemError" && error.reason === "NotFound"
|
|
308
|
+
? FilterNotFound.make({ name })
|
|
309
|
+
: toLibraryError(
|
|
310
|
+
`Failed to delete filter "${name}"`,
|
|
311
|
+
name,
|
|
312
|
+
filePath,
|
|
313
|
+
error
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const validateAll = Effect.fn("FilterLibrary.validateAll")(() =>
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
const names = yield* list();
|
|
322
|
+
const results = yield* Effect.forEach(
|
|
323
|
+
names,
|
|
324
|
+
(name) =>
|
|
325
|
+
Schema.decodeUnknown(StoreName)(name).pipe(
|
|
326
|
+
Effect.mapError((error) =>
|
|
327
|
+
toLibraryError(
|
|
328
|
+
`Invalid filter name "${name}": ${formatSchemaError(error)}`,
|
|
329
|
+
name,
|
|
330
|
+
rootDir,
|
|
331
|
+
error
|
|
332
|
+
)
|
|
333
|
+
),
|
|
334
|
+
Effect.flatMap((decoded) =>
|
|
335
|
+
get(decoded).pipe(
|
|
336
|
+
Effect.as({ name, ok: true } as const),
|
|
337
|
+
Effect.catchAll((error) =>
|
|
338
|
+
Effect.succeed({
|
|
339
|
+
name,
|
|
340
|
+
ok: false,
|
|
341
|
+
error:
|
|
342
|
+
error instanceof FilterNotFound
|
|
343
|
+
? `Filter "${name}" not found`
|
|
344
|
+
: error instanceof FilterLibraryError
|
|
345
|
+
? error.message
|
|
346
|
+
: String(error)
|
|
347
|
+
})
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
),
|
|
351
|
+
Effect.catchAll((error) =>
|
|
352
|
+
Effect.succeed({
|
|
353
|
+
name,
|
|
354
|
+
ok: false,
|
|
355
|
+
error:
|
|
356
|
+
error instanceof FilterLibraryError
|
|
357
|
+
? error.message
|
|
358
|
+
: String(error)
|
|
359
|
+
})
|
|
360
|
+
)
|
|
361
|
+
),
|
|
362
|
+
{ discard: false }
|
|
363
|
+
);
|
|
364
|
+
return results;
|
|
365
|
+
})
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return FilterLibrary.of({ list, get, save, remove, validateAll });
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
}
|