@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,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
+ }