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