@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,89 @@
1
+ /**
2
+ * Lineage Store Service
3
+ *
4
+ * Tracks store derivation lineage, recording parent-child relationships between
5
+ * stores. This enables understanding which stores are derived from others and
6
+ * managing derivation dependencies.
7
+ *
8
+ * Lineage information is used to:
9
+ * - Track which source stores a derived store depends on
10
+ * - Validate that derivation sources haven't changed unexpectedly
11
+ * - Rebuild derivation chains when needed
12
+ *
13
+ * @module services/lineage-store
14
+ */
15
+
16
+ import * as KeyValueStore from "@effect/platform/KeyValueStore";
17
+ import { Context, Effect, Layer, Option, Schema } from "effect";
18
+ import { StoreLineage } from "../domain/derivation.js";
19
+ import { StoreName, StorePath } from "../domain/primitives.js";
20
+ import { StoreIoError } from "../domain/errors.js";
21
+
22
+ /**
23
+ * Generates the storage key for a store's lineage information.
24
+ * @param storeName - The name of the store
25
+ * @returns The storage key string
26
+ */
27
+ const lineageKey = (storeName: StoreName) => `stores/${storeName}/lineage`;
28
+
29
+ /**
30
+ * Converts an error to a StoreIoError with context for the lineage path.
31
+ * @param storeName - The name of the store
32
+ * @returns A function that creates StoreIoError from any cause
33
+ */
34
+ const toStoreIoError = (storeName: StoreName) => (cause: unknown) => {
35
+ const path = Schema.decodeUnknownSync(StorePath)(`stores/${storeName}/lineage`);
36
+ return StoreIoError.make({ path, cause });
37
+ };
38
+
39
+ /**
40
+ * Service for managing store derivation lineage.
41
+ *
42
+ * This service tracks parent-child relationships between stores, recording
43
+ * which source stores were used to derive a given store. This enables
44
+ * dependency tracking and validation of derivation chains.
45
+ */
46
+ export class LineageStore extends Context.Tag("@skygent/LineageStore")<
47
+ LineageStore,
48
+ {
49
+ /**
50
+ * Retrieves the lineage information for a store.
51
+ *
52
+ * @param storeName - The name of the store to get lineage for
53
+ * @returns Effect resolving to Option of StoreLineage, or StoreIoError on failure
54
+ */
55
+ readonly get: (
56
+ storeName: StoreName
57
+ ) => Effect.Effect<Option.Option<StoreLineage>, StoreIoError>;
58
+
59
+ /**
60
+ * Saves lineage information for a store.
61
+ *
62
+ * @param lineage - The lineage data to persist
63
+ * @returns Effect resolving to void, or StoreIoError on failure
64
+ */
65
+ readonly save: (lineage: StoreLineage) => Effect.Effect<void, StoreIoError>;
66
+ }
67
+ >() {
68
+ static readonly layer = Layer.effect(
69
+ LineageStore,
70
+ Effect.gen(function* () {
71
+ const kv = yield* KeyValueStore.KeyValueStore;
72
+ const lineages = kv.forSchema(StoreLineage);
73
+
74
+ const get = Effect.fn("LineageStore.get")((storeName: StoreName) =>
75
+ lineages
76
+ .get(lineageKey(storeName))
77
+ .pipe(Effect.mapError(toStoreIoError(storeName)))
78
+ );
79
+
80
+ const save = Effect.fn("LineageStore.save")((lineage: StoreLineage) =>
81
+ lineages
82
+ .set(lineageKey(lineage.storeName), lineage)
83
+ .pipe(Effect.mapError(toStoreIoError(lineage.storeName)))
84
+ );
85
+
86
+ return LineageStore.of({ get, save });
87
+ })
88
+ );
89
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Link Validator Service
3
+ *
4
+ * Validates external URLs found in Bluesky posts by performing HTTP HEAD requests.
5
+ * Used by the HasValidLinks filter to check if post links are accessible.
6
+ *
7
+ * **Validation Process:**
8
+ * 1. Checks URL format (must be http:// or https://)
9
+ * 2. Attempts HEAD request
10
+ * 3. Falls back to GET if HEAD returns 405 (Method Not Allowed) or 501 (Not Implemented)
11
+ * 4. Returns true for 2xx-3xx status codes
12
+ *
13
+ * **Caching:**
14
+ * Results are cached for 6 hours (configurable via cacheTtl) to avoid
15
+ * repeated requests to the same URLs. Cache entries include:
16
+ * - URL (encoded as cache key)
17
+ * - ok: boolean (whether link is valid)
18
+ * - status: number (HTTP status code, optional)
19
+ * - checkedAt: Date (when validation occurred)
20
+ *
21
+ * **Performance:**
22
+ * Cached results are used if entry is fresh (within TTL).
23
+ * hasValidLink returns true on first valid URL in the array (short-circuits).
24
+ *
25
+ * @module services/link-validator
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * import { LinkValidator } from "./services/link-validator.js";
30
+ * import { Effect } from "effect";
31
+ *
32
+ * const program = Effect.gen(function* () {
33
+ * const validator = yield* LinkValidator;
34
+ *
35
+ * // Validate a single link
36
+ * const isValid = yield* validator.isValid("https://example.com");
37
+ * console.log(`Link valid: ${isValid}`);
38
+ *
39
+ * // Check if any link in array is valid
40
+ * const hasValid = yield* validator.hasValidLink([
41
+ * "https://example.com",
42
+ * "https://invalid-url.xyz"
43
+ * ]);
44
+ * console.log(`Has valid link: ${hasValid}`); // true if at least one valid
45
+ * }).pipe(Effect.provide(LinkValidator.layer));
46
+ * ```
47
+ */
48
+
49
+ import { HttpClient } from "@effect/platform";
50
+ import * as KeyValueStore from "@effect/platform/KeyValueStore";
51
+ import { Clock, Context, Duration, Effect, Layer, Option, Schema } from "effect";
52
+ import { FilterEvalError } from "../domain/errors.js";
53
+
54
+ const cachePrefix = "cache/links/";
55
+ const cacheTtl = Duration.hours(6);
56
+
57
+ const toFilterEvalError = (message: string) => (cause: unknown) =>
58
+ FilterEvalError.make({ message, cause });
59
+
60
+ const isHttpUrl = (value: string) => {
61
+ try {
62
+ const url = new URL(value);
63
+ return url.protocol === "http:" || url.protocol === "https:";
64
+ } catch {
65
+ return false;
66
+ }
67
+ };
68
+
69
+ const isValidStatus = (status: number) => status >= 200 && status < 400;
70
+
71
+ const cacheKey = (url: string) => encodeURIComponent(url);
72
+
73
+ class LinkCacheEntry extends Schema.Class<LinkCacheEntry>("LinkCacheEntry")({
74
+ url: Schema.String,
75
+ ok: Schema.Boolean,
76
+ status: Schema.optional(Schema.Number),
77
+ checkedAt: Schema.DateFromString
78
+ }) {}
79
+
80
+ /**
81
+ * Interface for the link validator service.
82
+ * Provides URL validation with caching support.
83
+ */
84
+ export type LinkValidatorService = {
85
+ /**
86
+ * Validates a single URL by checking HTTP accessibility.
87
+ * Returns cached result if available and fresh (within 6 hour TTL).
88
+ *
89
+ * @param url - The URL to validate
90
+ * @returns Effect resolving to true if link is accessible (2xx-3xx status), false otherwise
91
+ * @throws {FilterEvalError} When HTTP request fails unexpectedly or cache operations fail
92
+ */
93
+ readonly isValid: (url: string) => Effect.Effect<boolean, FilterEvalError>;
94
+
95
+ /**
96
+ * Checks if any URL in the array is valid.
97
+ * Short-circuits on first valid URL for performance.
98
+ * Non-HTTP URLs are skipped (not considered valid).
99
+ *
100
+ * @param urls - Array of URLs to check
101
+ * @returns Effect resolving to true if at least one URL is valid
102
+ * @throws {FilterEvalError} When validation fails
103
+ */
104
+ readonly hasValidLink: (
105
+ urls: ReadonlyArray<string>
106
+ ) => Effect.Effect<boolean, FilterEvalError>;
107
+ };
108
+
109
+ /**
110
+ * Context tag and Layer implementation for the link validator service.
111
+ * Performs HTTP validation with caching via KeyValueStore.
112
+ *
113
+ * **HTTP Behavior:**
114
+ * - Uses HEAD requests for efficiency
115
+ * - Falls back to GET for servers that don't support HEAD (405/501)
116
+ * - Considers 2xx-3xx status codes as valid
117
+ * - Non-HTTP URLs (ftp://, file://, etc.) are considered invalid
118
+ *
119
+ * **Caching:**
120
+ * Uses KeyValueStore with "cache/links/" prefix.
121
+ * Cache entries expire after 6 hours.
122
+ *
123
+ * **Dependencies:**
124
+ * - KeyValueStore.KeyValueStore: For caching
125
+ * - HttpClient.HttpClient: For making requests
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * // Basic usage
130
+ * const isValid = yield* validator.isValid("https://example.com");
131
+ *
132
+ * // Check multiple links (stops at first valid)
133
+ * const hasAnyValid = yield* validator.hasValidLink([
134
+ * "https://broken.example",
135
+ * "https://working.example"
136
+ * ]);
137
+ *
138
+ * // Testing with mock responses
139
+ * const testLayer = Layer.succeed(
140
+ * LinkValidator,
141
+ * LinkValidator.of({
142
+ * isValid: (url) => Effect.succeed(url.includes("ok")),
143
+ * hasValidLink: (urls) => Effect.succeed(urls.some(u => u.includes("ok")))
144
+ * })
145
+ * );
146
+ * ```
147
+ */
148
+ export class LinkValidator extends Context.Tag("@skygent/LinkValidator")<
149
+ LinkValidator,
150
+ LinkValidatorService
151
+ >() {
152
+ /**
153
+ * Layer that creates the link validator service.
154
+ * Requires KeyValueStore and HttpClient services.
155
+ */
156
+ static readonly layer = Layer.effect(
157
+ LinkValidator,
158
+ Effect.gen(function* () {
159
+ const kv = yield* KeyValueStore.KeyValueStore;
160
+ const http = yield* HttpClient.HttpClient;
161
+ const store = KeyValueStore.prefix(kv.forSchema(LinkCacheEntry), cachePrefix);
162
+
163
+ const isFresh = (entry: LinkCacheEntry, now: number) =>
164
+ now - entry.checkedAt.getTime() < Duration.toMillis(cacheTtl);
165
+
166
+ const fetchStatus = (url: string) =>
167
+ http.head(url).pipe(
168
+ Effect.map((response) => response.status),
169
+ Effect.flatMap((status) =>
170
+ status === 405 || status === 501
171
+ ? http.get(url).pipe(Effect.map((response) => response.status))
172
+ : Effect.succeed(status)
173
+ ),
174
+ Effect.mapError(toFilterEvalError("Link validation failed"))
175
+ );
176
+
177
+ const isValid = Effect.fn("LinkValidator.isValid")((url: string) =>
178
+ Effect.gen(function* () {
179
+ if (!isHttpUrl(url)) {
180
+ return false;
181
+ }
182
+
183
+ const now = yield* Clock.currentTimeMillis;
184
+ const cached = yield* store
185
+ .get(cacheKey(url))
186
+ .pipe(Effect.mapError(toFilterEvalError("Link cache read failed")));
187
+
188
+ if (Option.isSome(cached) && isFresh(cached.value, now)) {
189
+ return cached.value.ok;
190
+ }
191
+
192
+ const status = yield* fetchStatus(url);
193
+ const ok = isValidStatus(status);
194
+ const entry = LinkCacheEntry.make({
195
+ url,
196
+ ok,
197
+ status,
198
+ checkedAt: new Date(now)
199
+ });
200
+
201
+ yield* store
202
+ .set(cacheKey(url), entry)
203
+ .pipe(Effect.mapError(toFilterEvalError("Link cache write failed")));
204
+
205
+ return ok;
206
+ })
207
+ );
208
+
209
+ const hasValidLink = Effect.fn("LinkValidator.hasValidLink")(
210
+ (urls: ReadonlyArray<string>) =>
211
+ Effect.findFirst(urls, (url) => isValid(url)).pipe(
212
+ Effect.map(Option.isSome)
213
+ )
214
+ );
215
+
216
+ return LinkValidator.of({ isValid, hasValidLink });
217
+ })
218
+ );
219
+
220
+ /**
221
+ * Test layer that provides a mock link validator.
222
+ * URLs containing "ok" are considered valid.
223
+ * Useful for testing without making actual HTTP requests.
224
+ *
225
+ * @example
226
+ * ```typescript
227
+ * // Mock validator that considers URLs with "ok" as valid
228
+ * const testProgram = program.pipe(
229
+ * Effect.provide(LinkValidator.testLayer)
230
+ * );
231
+ *
232
+ * // "https://example.com/ok" -> true
233
+ * // "https://broken.example" -> false
234
+ * ```
235
+ */
236
+ static readonly testLayer = Layer.succeed(
237
+ LinkValidator,
238
+ LinkValidator.of({
239
+ isValid: (url) => Effect.succeed(url.includes("ok")),
240
+ hasValidLink: (urls) =>
241
+ Effect.succeed(urls.some((url) => url.includes("ok")))
242
+ })
243
+ );
244
+ }
@@ -0,0 +1,274 @@
1
+ import { FileSystem, Path } from "@effect/platform";
2
+ import { Chunk, Clock, Context, Effect, Layer, Option, Ref, Schema, Stream } from "effect";
3
+ import { StoreQuery } from "../domain/events.js";
4
+ import type { Post } from "../domain/post.js";
5
+ import { Post as PostSchema } from "../domain/post.js";
6
+ import type { FilterSpec, StoreRef } from "../domain/store.js";
7
+ import { FilterCompiler } from "./filter-compiler.js";
8
+ import { FilterRuntime } from "./filter-runtime.js";
9
+ import { StoreIndex } from "./store-index.js";
10
+ import { StoreManager } from "./store-manager.js";
11
+ import { AppConfigService } from "./app-config.js";
12
+ import { traverseFilterEffect } from "../typeclass/chunk.js";
13
+ import { renderPostMarkdownRow, renderPostsMarkdownHeader } from "../domain/format.js";
14
+ import {
15
+ FilterCompileError,
16
+ FilterEvalError,
17
+ StoreIndexError,
18
+ StoreIoError
19
+ } from "../domain/errors.js";
20
+ import { defaultStoreConfig } from "../domain/defaults.js";
21
+ import { StorePath, Timestamp } from "../domain/primitives.js";
22
+ import { FilterSettings } from "./filter-settings.js";
23
+
24
+ export interface MaterializedFilterOutput {
25
+ readonly name: string;
26
+ readonly outputPath: string;
27
+ readonly jsonPath: string | undefined;
28
+ readonly markdownPath: string | undefined;
29
+ readonly count: number;
30
+ readonly updatedAt: Date;
31
+ }
32
+
33
+ export interface MaterializedStoreOutput {
34
+ readonly store: string;
35
+ readonly filters: ReadonlyArray<MaterializedFilterOutput>;
36
+ }
37
+
38
+ const toStoreIoError = (path: string) => (cause: unknown) =>
39
+ StoreIoError.make({
40
+ path: Schema.decodeUnknownSync(StorePath)(path),
41
+ cause
42
+ });
43
+
44
+ const ensureTrailingNewline = (value: string) =>
45
+ value.endsWith("\n") ? value : `${value}\n`;
46
+
47
+ const encodePostJson = (post: Post) =>
48
+ Schema.encodeSync(Schema.parseJson(PostSchema))(post);
49
+
50
+ const encodeManifestJson = (manifest: unknown) =>
51
+ Schema.encodeSync(Schema.parseJson(Schema.Unknown))(manifest);
52
+
53
+ const appendFileString = (
54
+ fs: FileSystem.FileSystem,
55
+ targetPath: string,
56
+ value: string
57
+ ) =>
58
+ fs
59
+ .writeFileString(targetPath, value, { flag: "a" })
60
+ .pipe(Effect.mapError(toStoreIoError(targetPath)));
61
+
62
+ const resolveOutputDir = (
63
+ path: Path.Path,
64
+ storeRoot: string,
65
+ store: StoreRef,
66
+ outputPath: string
67
+ ) => {
68
+ if (path.isAbsolute(outputPath)) {
69
+ return outputPath;
70
+ }
71
+ return path.join(storeRoot, store.root, outputPath);
72
+ };
73
+
74
+ const materializeFilter = Effect.fn("OutputManager.materializeFilter")(
75
+ (store: StoreRef, spec: FilterSpec) =>
76
+ Effect.gen(function* () {
77
+ const config = yield* AppConfigService;
78
+ const fs = yield* FileSystem.FileSystem;
79
+ const path = yield* Path.Path;
80
+ const compiler = yield* FilterCompiler;
81
+ const runtime = yield* FilterRuntime;
82
+ const index = yield* StoreIndex;
83
+ const filterSettings = yield* FilterSettings;
84
+
85
+ const outputDir = resolveOutputDir(path, config.storeRoot, store, spec.output.path);
86
+ yield* fs
87
+ .makeDirectory(outputDir, { recursive: true })
88
+ .pipe(Effect.mapError(toStoreIoError(outputDir)));
89
+
90
+ const expr = yield* compiler.compile(spec);
91
+ const stream = index.query(store, StoreQuery.make({ filter: expr }));
92
+ const predicate = yield* runtime.evaluate(expr);
93
+ const filtered = stream.pipe(
94
+ Stream.grouped(50),
95
+ Stream.mapEffect((batch) =>
96
+ traverseFilterEffect(batch, predicate, {
97
+ concurrency: filterSettings.concurrency,
98
+ batching: true
99
+ }).pipe(Effect.withRequestBatching(true))
100
+ ),
101
+ Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk))
102
+ );
103
+ const jsonPath = spec.output.json ? path.join(outputDir, "posts.json") : undefined;
104
+ const markdownPath = spec.output.markdown ? path.join(outputDir, "posts.md") : undefined;
105
+
106
+ if (jsonPath) {
107
+ yield* fs
108
+ .writeFileString(jsonPath, "[")
109
+ .pipe(Effect.mapError(toStoreIoError(jsonPath)));
110
+ }
111
+ if (markdownPath) {
112
+ const header = `${renderPostsMarkdownHeader()}\n`;
113
+ yield* fs
114
+ .writeFileString(markdownPath, header)
115
+ .pipe(Effect.mapError(toStoreIoError(markdownPath)));
116
+ }
117
+
118
+ const countRef = yield* Ref.make(0);
119
+ const jsonFirstRef = jsonPath ? yield* Ref.make(true) : undefined;
120
+
121
+ const writeBatch = (batch: Chunk.Chunk<Post>) =>
122
+ Effect.gen(function* () {
123
+ const posts = Chunk.toReadonlyArray(batch);
124
+ if (posts.length === 0) {
125
+ return;
126
+ }
127
+ if (jsonPath && jsonFirstRef) {
128
+ const isFirst = yield* Ref.get(jsonFirstRef);
129
+ let first = isFirst;
130
+ const jsonChunk = posts
131
+ .map((post) => {
132
+ const prefix = first ? "" : ",";
133
+ first = false;
134
+ return `${prefix}${encodePostJson(post)}`;
135
+ })
136
+ .join("");
137
+ yield* appendFileString(fs, jsonPath, jsonChunk);
138
+ if (isFirst) {
139
+ yield* Ref.set(jsonFirstRef, false);
140
+ }
141
+ }
142
+ if (markdownPath) {
143
+ const markdownChunk = posts
144
+ .map((post) => `${renderPostMarkdownRow(post)}\n`)
145
+ .join("");
146
+ yield* appendFileString(fs, markdownPath, markdownChunk);
147
+ }
148
+ yield* Ref.update(countRef, (count) => count + posts.length);
149
+ });
150
+
151
+ yield* filtered.pipe(Stream.grouped(50), Stream.runForEach(writeBatch));
152
+
153
+ if (jsonPath && jsonFirstRef) {
154
+ yield* appendFileString(fs, jsonPath, "]\n");
155
+ }
156
+
157
+ const postsCount = yield* Ref.get(countRef);
158
+
159
+ const updatedAt = yield* Clock.currentTimeMillis.pipe(
160
+ Effect.flatMap((now) =>
161
+ Schema.decodeUnknown(Timestamp)(new Date(now).toISOString())
162
+ ),
163
+ Effect.orDie
164
+ );
165
+
166
+ const manifest = {
167
+ name: spec.name,
168
+ count: postsCount,
169
+ updatedAt: updatedAt.toISOString(),
170
+ output: {
171
+ json: jsonPath ? path.basename(jsonPath) : undefined,
172
+ markdown: markdownPath ? path.basename(markdownPath) : undefined
173
+ }
174
+ };
175
+ const manifestPath = path.join(outputDir, "manifest.json");
176
+ yield* fs
177
+ .writeFileString(
178
+ manifestPath,
179
+ ensureTrailingNewline(encodeManifestJson(manifest))
180
+ )
181
+ .pipe(Effect.mapError(toStoreIoError(manifestPath)));
182
+
183
+ return {
184
+ name: spec.name,
185
+ outputPath: outputDir,
186
+ jsonPath,
187
+ markdownPath,
188
+ count: postsCount,
189
+ updatedAt
190
+ } satisfies MaterializedFilterOutput;
191
+ })
192
+ );
193
+
194
+ const materializeFilters = (store: StoreRef, filters: ReadonlyArray<FilterSpec>) =>
195
+ Effect.forEach(filters, (spec) => materializeFilter(store, spec), {
196
+ discard: false
197
+ });
198
+
199
+ export class OutputManager extends Context.Tag("@skygent/OutputManager")<
200
+ OutputManager,
201
+ {
202
+ readonly materializeStore: (
203
+ store: StoreRef
204
+ ) => Effect.Effect<
205
+ MaterializedStoreOutput,
206
+ FilterCompileError | FilterEvalError | StoreIndexError | StoreIoError
207
+ >;
208
+ readonly materializeFilters: (
209
+ store: StoreRef,
210
+ filters: ReadonlyArray<FilterSpec>
211
+ ) => Effect.Effect<
212
+ ReadonlyArray<MaterializedFilterOutput>,
213
+ FilterCompileError | FilterEvalError | StoreIndexError | StoreIoError
214
+ >;
215
+ }
216
+ >() {
217
+ static readonly layer = Layer.effect(
218
+ OutputManager,
219
+ Effect.gen(function* () {
220
+ const appConfig = yield* AppConfigService;
221
+ const fs = yield* FileSystem.FileSystem;
222
+ const path = yield* Path.Path;
223
+ const compiler = yield* FilterCompiler;
224
+ const runtime = yield* FilterRuntime;
225
+ const index = yield* StoreIndex;
226
+ const manager = yield* StoreManager;
227
+ const filterSettings = yield* FilterSettings;
228
+
229
+ type OutputManagerDeps =
230
+ | AppConfigService
231
+ | FileSystem.FileSystem
232
+ | Path.Path
233
+ | FilterCompiler
234
+ | FilterRuntime
235
+ | StoreIndex
236
+ | FilterSettings;
237
+
238
+ const provideDeps = <A, E>(effect: Effect.Effect<A, E, OutputManagerDeps>) =>
239
+ effect.pipe(
240
+ Effect.provideService(AppConfigService, appConfig),
241
+ Effect.provideService(FileSystem.FileSystem, fs),
242
+ Effect.provideService(Path.Path, path),
243
+ Effect.provideService(FilterCompiler, compiler),
244
+ Effect.provideService(FilterRuntime, runtime),
245
+ Effect.provideService(StoreIndex, index),
246
+ Effect.provideService(FilterSettings, filterSettings)
247
+ );
248
+
249
+ const materializeStore = Effect.fn("OutputManager.materializeStore")(
250
+ (store: StoreRef) =>
251
+ Effect.gen(function* () {
252
+ const configOption = yield* manager.getConfig(store.name);
253
+ const config = Option.getOrElse(configOption, () => defaultStoreConfig);
254
+
255
+ const results = yield* provideDeps(materializeFilters(store, config.filters));
256
+ return {
257
+ store: store.name,
258
+ filters: results
259
+ } satisfies MaterializedStoreOutput;
260
+ })
261
+ );
262
+
263
+ const materializeFiltersFn = Effect.fn("OutputManager.materializeFilters")(
264
+ (store: StoreRef, filters: ReadonlyArray<FilterSpec>) =>
265
+ provideDeps(materializeFilters(store, filters))
266
+ );
267
+
268
+ return OutputManager.of({
269
+ materializeStore,
270
+ materializeFilters: materializeFiltersFn
271
+ });
272
+ })
273
+ );
274
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Post Parser Service Module
3
+ *
4
+ * This module provides the PostParser service, which is responsible for parsing
5
+ * raw post data into normalized Post domain objects. It uses Effect's Schema
6
+ * validation to ensure type safety and data integrity during the parsing process.
7
+ *
8
+ * Key responsibilities:
9
+ * - Decode unknown/raw data into typed Post objects
10
+ * - Schema validation and error handling via ParseResult
11
+ * - Centralized parsing logic for post data ingestion
12
+ */
13
+
14
+ import { Context, Effect, Layer, ParseResult, Schema } from "effect";
15
+ import { Post } from "../domain/post.js";
16
+ import { PostFromRaw } from "../domain/raw.js";
17
+
18
+ /**
19
+ * Service for parsing raw post data into normalized Post domain objects.
20
+ *
21
+ * The PostParser provides a single `parsePost` method that uses Effect's Schema
22
+ * decoding to transform unknown/raw data into properly typed Post objects.
23
+ * This ensures type safety and validation during data ingestion.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const program = Effect.gen(function* () {
28
+ * const parser = yield* PostParser;
29
+ * const rawData = { uri: "at://...", cid: "...", text: "Hello" };
30
+ *
31
+ * const post = yield* parser.parsePost(rawData);
32
+ * return post;
33
+ * });
34
+ * ```
35
+ */
36
+ export class PostParser extends Context.Tag("@skygent/PostParser")<
37
+ PostParser,
38
+ {
39
+ /**
40
+ * Parse raw post data into a normalized Post object.
41
+ *
42
+ * This method uses Schema.decodeUnknown with the PostFromRaw schema to:
43
+ * 1. Validate the input data structure
44
+ * 2. Transform raw fields into the normalized Post type
45
+ * 3. Return a typed Post object or a ParseError on failure
46
+ *
47
+ * @param raw - The raw, unknown data to parse (typically from an external source)
48
+ * @returns Effect that resolves to a validated Post object, or fails with ParseError
49
+ * @throws ParseResult.ParseError if the input data doesn't match the expected schema
50
+ */
51
+ readonly parsePost: (raw: unknown) => Effect.Effect<Post, ParseResult.ParseError>;
52
+ }
53
+ >() {
54
+ static readonly layer = Layer.succeed(
55
+ PostParser,
56
+ PostParser.of({
57
+ parsePost: Effect.fn("PostParser.parsePost")((raw: unknown) =>
58
+ Schema.decodeUnknown(PostFromRaw)(raw)
59
+ )
60
+ })
61
+ );
62
+ }