@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
package/src/cli/sync.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { Command, Options } from "@effect/cli";
|
|
2
|
+
import { Duration, Effect, Layer, Option } from "effect";
|
|
3
|
+
import { Jetstream } from "effect-jetstream";
|
|
4
|
+
import { filterExprSignature } from "../domain/filter.js";
|
|
5
|
+
import { DataSource, SyncResult } from "../domain/sync.js";
|
|
6
|
+
import { JetstreamSyncEngine } from "../services/jetstream-sync.js";
|
|
7
|
+
import { storeOptions } from "./store.js";
|
|
8
|
+
import { logInfo, makeSyncReporter } from "./logging.js";
|
|
9
|
+
import { SyncReporter } from "../services/sync-reporter.js";
|
|
10
|
+
import { ResourceMonitor } from "../services/resource-monitor.js";
|
|
11
|
+
import { OutputManager } from "../services/output-manager.js";
|
|
12
|
+
import { CliOutput, writeJson } from "./output.js";
|
|
13
|
+
import { parseFilterExpr } from "./filter-input.js";
|
|
14
|
+
import { withExamples } from "./help.js";
|
|
15
|
+
import { buildJetstreamSelection, jetstreamOptions } from "./jetstream.js";
|
|
16
|
+
import { CliInputError } from "./errors.js";
|
|
17
|
+
import { makeSyncCommandBody } from "./sync-factory.js";
|
|
18
|
+
import {
|
|
19
|
+
feedUriArg,
|
|
20
|
+
listUriArg,
|
|
21
|
+
postUriArg,
|
|
22
|
+
actorArg,
|
|
23
|
+
storeNameOption,
|
|
24
|
+
filterOption,
|
|
25
|
+
filterJsonOption,
|
|
26
|
+
postFilterOption,
|
|
27
|
+
postFilterJsonOption,
|
|
28
|
+
authorFilterOption,
|
|
29
|
+
includePinsOption,
|
|
30
|
+
decodeActor,
|
|
31
|
+
quietOption,
|
|
32
|
+
refreshOption,
|
|
33
|
+
strictOption,
|
|
34
|
+
maxErrorsOption,
|
|
35
|
+
parseMaxErrors,
|
|
36
|
+
parseLimit,
|
|
37
|
+
parseBoundedIntOption
|
|
38
|
+
} from "./shared-options.js";
|
|
39
|
+
|
|
40
|
+
const limitOption = Options.integer("limit").pipe(
|
|
41
|
+
Options.withDescription("Maximum number of Jetstream events to process"),
|
|
42
|
+
Options.optional
|
|
43
|
+
);
|
|
44
|
+
const durationOption = Options.text("duration").pipe(
|
|
45
|
+
Options.withDescription("Stop after a duration (e.g. \"2 minutes\")"),
|
|
46
|
+
Options.optional
|
|
47
|
+
);
|
|
48
|
+
const depthOption = Options.integer("depth").pipe(
|
|
49
|
+
Options.withDescription("Thread reply depth to include (0-1000, default 6)"),
|
|
50
|
+
Options.optional
|
|
51
|
+
);
|
|
52
|
+
const parentHeightOption = Options.integer("parent-height").pipe(
|
|
53
|
+
Options.withDescription("Thread parent height to include (0-1000, default 80)"),
|
|
54
|
+
Options.optional
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const parseDuration = (value: Option.Option<string>) =>
|
|
58
|
+
Option.match(value, {
|
|
59
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
60
|
+
onSome: (raw) =>
|
|
61
|
+
Effect.try({
|
|
62
|
+
try: () => Duration.decode(raw as Duration.DurationInput),
|
|
63
|
+
catch: (cause) =>
|
|
64
|
+
CliInputError.make({
|
|
65
|
+
message: `Invalid duration: ${raw}. Use formats like \"2 minutes\".`,
|
|
66
|
+
cause
|
|
67
|
+
})
|
|
68
|
+
}).pipe(
|
|
69
|
+
Effect.flatMap((duration) =>
|
|
70
|
+
Duration.toMillis(duration) < 0
|
|
71
|
+
? Effect.fail(
|
|
72
|
+
CliInputError.make({
|
|
73
|
+
message: "Duration must be non-negative.",
|
|
74
|
+
cause: duration
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
: Effect.succeed(Option.some(duration))
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const timelineCommand = Command.make(
|
|
83
|
+
"timeline",
|
|
84
|
+
{ store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
|
|
85
|
+
makeSyncCommandBody("timeline", () => DataSource.timeline())
|
|
86
|
+
).pipe(
|
|
87
|
+
Command.withDescription(
|
|
88
|
+
withExamples(
|
|
89
|
+
"Sync the authenticated timeline into a store",
|
|
90
|
+
[
|
|
91
|
+
"skygent sync timeline --store my-store",
|
|
92
|
+
"skygent sync timeline --store my-store --filter 'hashtag:#ai' --quiet"
|
|
93
|
+
],
|
|
94
|
+
["Tip: add --quiet to suppress progress logs."]
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const feedCommand = Command.make(
|
|
100
|
+
"feed",
|
|
101
|
+
{ uri: feedUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
|
|
102
|
+
({ uri, ...rest }) => makeSyncCommandBody("feed", () => DataSource.feed(uri), { uri })(rest)
|
|
103
|
+
).pipe(
|
|
104
|
+
Command.withDescription(
|
|
105
|
+
withExamples(
|
|
106
|
+
"Sync a feed URI into a store",
|
|
107
|
+
[
|
|
108
|
+
"skygent sync feed at://did:plc:example/app.bsky.feed.generator/xyz --store my-store"
|
|
109
|
+
],
|
|
110
|
+
["Tip: add --quiet to suppress progress logs."]
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const listCommand = Command.make(
|
|
116
|
+
"list",
|
|
117
|
+
{ uri: listUriArg, store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
|
|
118
|
+
({ uri, ...rest }) => makeSyncCommandBody("list", () => DataSource.list(uri), { uri })(rest)
|
|
119
|
+
).pipe(
|
|
120
|
+
Command.withDescription(
|
|
121
|
+
withExamples(
|
|
122
|
+
"Sync a list feed URI into a store",
|
|
123
|
+
[
|
|
124
|
+
"skygent sync list at://did:plc:example/app.bsky.graph.list/xyz --store my-store"
|
|
125
|
+
],
|
|
126
|
+
["Tip: add --quiet to suppress progress logs."]
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const notificationsCommand = Command.make(
|
|
132
|
+
"notifications",
|
|
133
|
+
{ store: storeNameOption, filter: filterOption, filterJson: filterJsonOption, quiet: quietOption, refresh: refreshOption },
|
|
134
|
+
makeSyncCommandBody("notifications", () => DataSource.notifications())
|
|
135
|
+
).pipe(
|
|
136
|
+
Command.withDescription(
|
|
137
|
+
withExamples(
|
|
138
|
+
"Sync notifications into a store",
|
|
139
|
+
["skygent sync notifications --store my-store --quiet"],
|
|
140
|
+
["Tip: add --quiet to suppress progress logs."]
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const authorCommand = Command.make(
|
|
146
|
+
"author",
|
|
147
|
+
{
|
|
148
|
+
actor: actorArg,
|
|
149
|
+
store: storeNameOption,
|
|
150
|
+
filter: authorFilterOption,
|
|
151
|
+
includePins: includePinsOption,
|
|
152
|
+
postFilter: postFilterOption,
|
|
153
|
+
postFilterJson: postFilterJsonOption,
|
|
154
|
+
quiet: quietOption,
|
|
155
|
+
refresh: refreshOption
|
|
156
|
+
},
|
|
157
|
+
({ actor, filter, includePins, postFilter, postFilterJson, store, quiet, refresh }) =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const resolvedActor = yield* decodeActor(actor);
|
|
160
|
+
const apiFilter = Option.getOrUndefined(filter);
|
|
161
|
+
const source = DataSource.author(resolvedActor, {
|
|
162
|
+
...(apiFilter !== undefined ? { filter: apiFilter } : {}),
|
|
163
|
+
...(includePins ? { includePins: true } : {})
|
|
164
|
+
});
|
|
165
|
+
const run = makeSyncCommandBody("author", () => source, {
|
|
166
|
+
actor: resolvedActor,
|
|
167
|
+
...(apiFilter !== undefined ? { filter: apiFilter } : {}),
|
|
168
|
+
...(includePins ? { includePins: true } : {})
|
|
169
|
+
});
|
|
170
|
+
return yield* run({
|
|
171
|
+
store,
|
|
172
|
+
filter: postFilter,
|
|
173
|
+
filterJson: postFilterJson,
|
|
174
|
+
quiet,
|
|
175
|
+
refresh
|
|
176
|
+
});
|
|
177
|
+
})
|
|
178
|
+
).pipe(
|
|
179
|
+
Command.withDescription(
|
|
180
|
+
withExamples(
|
|
181
|
+
"Sync posts from a specific author",
|
|
182
|
+
[
|
|
183
|
+
"skygent sync author alice.bsky.social --store my-store",
|
|
184
|
+
"skygent sync author did:plc:example --store my-store --filter posts_no_replies --include-pins"
|
|
185
|
+
],
|
|
186
|
+
["Tip: use --post-filter to apply the DSL filter to synced posts."]
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const threadCommand = Command.make(
|
|
192
|
+
"thread",
|
|
193
|
+
{
|
|
194
|
+
uri: postUriArg,
|
|
195
|
+
store: storeNameOption,
|
|
196
|
+
depth: depthOption,
|
|
197
|
+
parentHeight: parentHeightOption,
|
|
198
|
+
filter: filterOption,
|
|
199
|
+
filterJson: filterJsonOption,
|
|
200
|
+
quiet: quietOption,
|
|
201
|
+
refresh: refreshOption
|
|
202
|
+
},
|
|
203
|
+
({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh }) =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
|
|
206
|
+
const parsedParentHeight = yield* parseBoundedIntOption(
|
|
207
|
+
parentHeight,
|
|
208
|
+
"parent-height",
|
|
209
|
+
0,
|
|
210
|
+
1000
|
|
211
|
+
);
|
|
212
|
+
const depthValue = Option.getOrUndefined(parsedDepth);
|
|
213
|
+
const parentHeightValue = Option.getOrUndefined(parsedParentHeight);
|
|
214
|
+
const source = DataSource.thread(uri, {
|
|
215
|
+
...(depthValue !== undefined ? { depth: depthValue } : {}),
|
|
216
|
+
...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
|
|
217
|
+
});
|
|
218
|
+
const run = makeSyncCommandBody("thread", () => source, {
|
|
219
|
+
uri,
|
|
220
|
+
...(depthValue !== undefined ? { depth: depthValue } : {}),
|
|
221
|
+
...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
|
|
222
|
+
});
|
|
223
|
+
return yield* run({ store, filter, filterJson, quiet, refresh });
|
|
224
|
+
})
|
|
225
|
+
).pipe(
|
|
226
|
+
Command.withDescription(
|
|
227
|
+
withExamples(
|
|
228
|
+
"Sync a post thread (parents + replies) into a store",
|
|
229
|
+
[
|
|
230
|
+
"skygent sync thread at://did:plc:example/app.bsky.feed.post/xyz --store my-store",
|
|
231
|
+
"skygent sync thread at://did:plc:example/app.bsky.feed.post/xyz --store my-store --depth 10 --parent-height 5"
|
|
232
|
+
],
|
|
233
|
+
["Tip: use --filter to apply the DSL filter to thread posts."]
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const jetstreamCommand = Command.make(
|
|
239
|
+
"jetstream",
|
|
240
|
+
{
|
|
241
|
+
store: storeNameOption,
|
|
242
|
+
filter: filterOption,
|
|
243
|
+
filterJson: filterJsonOption,
|
|
244
|
+
quiet: quietOption,
|
|
245
|
+
endpoint: jetstreamOptions.endpoint,
|
|
246
|
+
collections: jetstreamOptions.collections,
|
|
247
|
+
dids: jetstreamOptions.dids,
|
|
248
|
+
cursor: jetstreamOptions.cursor,
|
|
249
|
+
compress: jetstreamOptions.compress,
|
|
250
|
+
maxMessageSize: jetstreamOptions.maxMessageSize,
|
|
251
|
+
limit: limitOption,
|
|
252
|
+
duration: durationOption,
|
|
253
|
+
strict: strictOption,
|
|
254
|
+
maxErrors: maxErrorsOption
|
|
255
|
+
},
|
|
256
|
+
({
|
|
257
|
+
store,
|
|
258
|
+
filter,
|
|
259
|
+
filterJson,
|
|
260
|
+
quiet,
|
|
261
|
+
endpoint,
|
|
262
|
+
collections,
|
|
263
|
+
dids,
|
|
264
|
+
cursor,
|
|
265
|
+
compress,
|
|
266
|
+
maxMessageSize,
|
|
267
|
+
limit,
|
|
268
|
+
duration,
|
|
269
|
+
strict,
|
|
270
|
+
maxErrors
|
|
271
|
+
}) =>
|
|
272
|
+
Effect.gen(function* () {
|
|
273
|
+
const monitor = yield* ResourceMonitor;
|
|
274
|
+
const output = yield* CliOutput;
|
|
275
|
+
const outputManager = yield* OutputManager;
|
|
276
|
+
const storeRef = yield* storeOptions.loadStoreRef(store);
|
|
277
|
+
const expr = yield* parseFilterExpr(filter, filterJson);
|
|
278
|
+
const filterHash = filterExprSignature(expr);
|
|
279
|
+
const selection = yield* buildJetstreamSelection(
|
|
280
|
+
{
|
|
281
|
+
endpoint,
|
|
282
|
+
collections,
|
|
283
|
+
dids,
|
|
284
|
+
cursor,
|
|
285
|
+
compress,
|
|
286
|
+
maxMessageSize
|
|
287
|
+
},
|
|
288
|
+
storeRef,
|
|
289
|
+
filterHash
|
|
290
|
+
);
|
|
291
|
+
const parsedLimit = yield* parseLimit(limit);
|
|
292
|
+
const parsedDuration = yield* parseDuration(duration);
|
|
293
|
+
const parsedMaxErrors = yield* parseMaxErrors(maxErrors);
|
|
294
|
+
if (Option.isNone(parsedLimit) && Option.isNone(parsedDuration)) {
|
|
295
|
+
return yield* CliInputError.make({
|
|
296
|
+
message:
|
|
297
|
+
"Jetstream sync requires --limit or --duration. Use watch jetstream for continuous streaming.",
|
|
298
|
+
cause: { limit, duration }
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const engineLayer = JetstreamSyncEngine.layer.pipe(
|
|
302
|
+
Layer.provideMerge(Jetstream.live(selection.config))
|
|
303
|
+
);
|
|
304
|
+
yield* logInfo("Starting sync", {
|
|
305
|
+
source: "jetstream",
|
|
306
|
+
store: storeRef.name
|
|
307
|
+
});
|
|
308
|
+
const result = yield* Effect.gen(function* () {
|
|
309
|
+
const engine = yield* JetstreamSyncEngine;
|
|
310
|
+
const limitValue = Option.getOrUndefined(parsedLimit);
|
|
311
|
+
const durationValue = Option.getOrUndefined(parsedDuration);
|
|
312
|
+
const maxErrorsValue = Option.getOrUndefined(parsedMaxErrors);
|
|
313
|
+
return yield* engine.sync({
|
|
314
|
+
source: selection.source,
|
|
315
|
+
store: storeRef,
|
|
316
|
+
filter: expr,
|
|
317
|
+
command: "sync jetstream",
|
|
318
|
+
...(limitValue !== undefined ? { limit: limitValue } : {}),
|
|
319
|
+
...(durationValue !== undefined ? { duration: durationValue } : {}),
|
|
320
|
+
...(selection.cursor !== undefined ? { cursor: selection.cursor } : {}),
|
|
321
|
+
...(strict ? { strict } : {}),
|
|
322
|
+
...(maxErrorsValue !== undefined ? { maxErrors: maxErrorsValue } : {})
|
|
323
|
+
});
|
|
324
|
+
}).pipe(
|
|
325
|
+
Effect.provide(engineLayer),
|
|
326
|
+
Effect.provideService(SyncReporter, makeSyncReporter(quiet, monitor, output))
|
|
327
|
+
);
|
|
328
|
+
const materialized = yield* outputManager.materializeStore(storeRef);
|
|
329
|
+
if (materialized.filters.length > 0) {
|
|
330
|
+
yield* logInfo("Materialized filter outputs", {
|
|
331
|
+
store: storeRef.name,
|
|
332
|
+
filters: materialized.filters.map((spec) => spec.name)
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
yield* logInfo("Sync complete", { source: "jetstream", store: storeRef.name });
|
|
336
|
+
yield* writeJson(result as SyncResult);
|
|
337
|
+
})
|
|
338
|
+
).pipe(
|
|
339
|
+
Command.withDescription(
|
|
340
|
+
withExamples(
|
|
341
|
+
"Sync Jetstream events into a store (posts only)",
|
|
342
|
+
[
|
|
343
|
+
"skygent sync jetstream --store my-store --limit 500",
|
|
344
|
+
"skygent sync jetstream --store my-store --duration \"2 minutes\""
|
|
345
|
+
],
|
|
346
|
+
["Tip: use watch jetstream for continuous streaming."]
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
export const syncCommand = Command.make("sync", {}).pipe(
|
|
352
|
+
Command.withSubcommands([
|
|
353
|
+
timelineCommand,
|
|
354
|
+
feedCommand,
|
|
355
|
+
listCommand,
|
|
356
|
+
notificationsCommand,
|
|
357
|
+
authorCommand,
|
|
358
|
+
threadCommand,
|
|
359
|
+
jetstreamCommand
|
|
360
|
+
]),
|
|
361
|
+
Command.withDescription(
|
|
362
|
+
withExamples("Sync content into stores", [
|
|
363
|
+
"skygent sync timeline --store my-store"
|
|
364
|
+
])
|
|
365
|
+
)
|
|
366
|
+
);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Args, Command, Options } from "@effect/cli";
|
|
2
|
+
import { Chunk, Console, Effect, Option, Schema, Stream } from "effect";
|
|
3
|
+
import { PostUri, StoreName } from "../domain/primitives.js";
|
|
4
|
+
import type { Post } from "../domain/post.js";
|
|
5
|
+
import { all } from "../domain/filter.js";
|
|
6
|
+
import { StoreQuery } from "../domain/events.js";
|
|
7
|
+
import { DataSource } from "../domain/sync.js";
|
|
8
|
+
import { BskyClient } from "../services/bsky-client.js";
|
|
9
|
+
import { PostParser } from "../services/post-parser.js";
|
|
10
|
+
import { StoreIndex } from "../services/store-index.js";
|
|
11
|
+
import { SyncEngine } from "../services/sync-engine.js";
|
|
12
|
+
import { renderThread } from "./doc/thread.js";
|
|
13
|
+
import { renderPlain, renderAnsi } from "./doc/render.js";
|
|
14
|
+
import { writeJson, writeText } from "./output.js";
|
|
15
|
+
import { storeOptions } from "./store.js";
|
|
16
|
+
import { withExamples } from "./help.js";
|
|
17
|
+
import { CliInputError } from "./errors.js";
|
|
18
|
+
import { formatSchemaError } from "./shared.js";
|
|
19
|
+
import { parseBoundedIntOption } from "./shared-options.js";
|
|
20
|
+
import { textJsonFormats } from "./output-format.js";
|
|
21
|
+
|
|
22
|
+
const uriArg = Args.text({ name: "uri" }).pipe(
|
|
23
|
+
Args.withDescription("AT-URI of any post in the thread")
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const storeOption = Options.text("store").pipe(
|
|
27
|
+
Options.withSchema(StoreName),
|
|
28
|
+
Options.withDescription("Query from local store instead of API"),
|
|
29
|
+
Options.optional
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const compactOption = Options.boolean("compact").pipe(
|
|
33
|
+
Options.withDescription("Single-line rendering (default: card)")
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const ansiOption = Options.boolean("ansi").pipe(
|
|
37
|
+
Options.withDescription("Enable ANSI colors in output")
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const widthOption = Options.integer("width").pipe(
|
|
41
|
+
Options.withDescription("Line width for terminal output"),
|
|
42
|
+
Options.optional
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const formatOption = Options.choice("format", textJsonFormats).pipe(
|
|
46
|
+
Options.withDescription("Output format (default: text)"),
|
|
47
|
+
Options.optional
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const depthOption = Options.integer("depth").pipe(
|
|
51
|
+
Options.withDescription("Reply depth (API only, default: 6)"),
|
|
52
|
+
Options.optional
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const parentHeightOption = Options.integer("parent-height").pipe(
|
|
56
|
+
Options.withDescription("Parent height (API only, default: 80)"),
|
|
57
|
+
Options.optional
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
export const threadCommand = Command.make(
|
|
61
|
+
"thread",
|
|
62
|
+
{
|
|
63
|
+
uri: uriArg,
|
|
64
|
+
store: storeOption,
|
|
65
|
+
compact: compactOption,
|
|
66
|
+
ansi: ansiOption,
|
|
67
|
+
width: widthOption,
|
|
68
|
+
format: formatOption,
|
|
69
|
+
depth: depthOption,
|
|
70
|
+
parentHeight: parentHeightOption
|
|
71
|
+
},
|
|
72
|
+
({ uri, store, compact, ansi, width, format, depth, parentHeight }) =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const outputFormat = Option.getOrElse(format, () => "text" as const);
|
|
75
|
+
const w = Option.getOrUndefined(width);
|
|
76
|
+
const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
|
|
77
|
+
const parsedParentHeight = yield* parseBoundedIntOption(
|
|
78
|
+
parentHeight,
|
|
79
|
+
"parent-height",
|
|
80
|
+
0,
|
|
81
|
+
1000
|
|
82
|
+
);
|
|
83
|
+
const d = Option.getOrElse(parsedDepth, () => 6);
|
|
84
|
+
const ph = Option.getOrElse(parsedParentHeight, () => 80);
|
|
85
|
+
|
|
86
|
+
let posts: ReadonlyArray<Post>;
|
|
87
|
+
|
|
88
|
+
if (Option.isSome(store)) {
|
|
89
|
+
const index = yield* StoreIndex;
|
|
90
|
+
const storeRef = yield* storeOptions.loadStoreRef(store.value);
|
|
91
|
+
const targetUri = yield* Schema.decodeUnknown(PostUri)(uri).pipe(
|
|
92
|
+
Effect.mapError((error) =>
|
|
93
|
+
CliInputError.make({
|
|
94
|
+
message: `Invalid post URI: ${formatSchemaError(error)}`,
|
|
95
|
+
cause: error
|
|
96
|
+
})
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
const hasTarget = yield* index.hasUri(storeRef, targetUri);
|
|
100
|
+
if (!hasTarget) {
|
|
101
|
+
const engine = yield* SyncEngine;
|
|
102
|
+
const source = DataSource.thread(uri, { depth: d, parentHeight: ph });
|
|
103
|
+
yield* engine.sync(source, storeRef, all());
|
|
104
|
+
}
|
|
105
|
+
const query = StoreQuery.make({});
|
|
106
|
+
const stream = index.query(storeRef, query);
|
|
107
|
+
const collected = yield* Stream.runCollect(stream);
|
|
108
|
+
const allPosts = Chunk.toReadonlyArray(collected);
|
|
109
|
+
const threadPosts = selectThreadPosts(allPosts, String(targetUri));
|
|
110
|
+
if (threadPosts.length === 0) {
|
|
111
|
+
return yield* CliInputError.make({
|
|
112
|
+
message: `Thread not found for ${uri}.`,
|
|
113
|
+
cause: { uri, store: storeRef.name }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// B1: Hint when only root post exists in store
|
|
117
|
+
if (threadPosts.length === 1 && threadPosts[0]?.uri === targetUri) {
|
|
118
|
+
yield* Console.log("\nℹ️ Only root post found in store. Use --no-store to fetch full thread from API.\n");
|
|
119
|
+
}
|
|
120
|
+
posts = threadPosts;
|
|
121
|
+
} else {
|
|
122
|
+
const client = yield* BskyClient;
|
|
123
|
+
const parser = yield* PostParser;
|
|
124
|
+
const rawPosts = yield* client.getPostThread(uri, { depth: d, parentHeight: ph });
|
|
125
|
+
posts = yield* Effect.forEach(rawPosts, (raw) => parser.parsePost(raw));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (outputFormat === "json") {
|
|
129
|
+
yield* writeJson(posts);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const doc = renderThread(
|
|
134
|
+
posts,
|
|
135
|
+
w === undefined ? { compact } : { compact, lineWidth: w }
|
|
136
|
+
);
|
|
137
|
+
yield* writeText(ansi ? renderAnsi(doc, w) : renderPlain(doc, w));
|
|
138
|
+
})
|
|
139
|
+
).pipe(
|
|
140
|
+
Command.withDescription(
|
|
141
|
+
withExamples(
|
|
142
|
+
"Display a thread from the API or a local store",
|
|
143
|
+
[
|
|
144
|
+
"skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --ansi",
|
|
145
|
+
"skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --compact --ansi",
|
|
146
|
+
"skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --store my-store --ansi --width 100",
|
|
147
|
+
"skygent view thread at://did:plc:example/app.bsky.feed.post/xyz --format json"
|
|
148
|
+
]
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const selectThreadPosts = (posts: ReadonlyArray<Post>, targetUri: string) => {
|
|
154
|
+
const byUri = new Map(posts.map((post) => [String(post.uri), post]));
|
|
155
|
+
if (!byUri.has(targetUri)) {
|
|
156
|
+
return [] as ReadonlyArray<Post>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const childMap = new Map<string, Post[]>();
|
|
160
|
+
for (const post of posts) {
|
|
161
|
+
const parentUri = post.reply?.parent?.uri ? String(post.reply.parent.uri) : undefined;
|
|
162
|
+
if (!parentUri || !byUri.has(parentUri)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const siblings = childMap.get(parentUri) ?? [];
|
|
166
|
+
siblings.push(post);
|
|
167
|
+
childMap.set(parentUri, siblings);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const threadUris = new Set<string>();
|
|
171
|
+
let current: Post | undefined = byUri.get(targetUri);
|
|
172
|
+
while (current) {
|
|
173
|
+
const currentUri = String(current.uri);
|
|
174
|
+
threadUris.add(currentUri);
|
|
175
|
+
const parentUri = current.reply?.parent?.uri
|
|
176
|
+
? String(current.reply.parent.uri)
|
|
177
|
+
: undefined;
|
|
178
|
+
current = parentUri ? byUri.get(parentUri) : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const queue: Array<string> = [targetUri];
|
|
182
|
+
while (queue.length > 0) {
|
|
183
|
+
const next = queue.shift();
|
|
184
|
+
if (!next) break;
|
|
185
|
+
const children = childMap.get(next) ?? [];
|
|
186
|
+
for (const child of children) {
|
|
187
|
+
const childUri = String(child.uri);
|
|
188
|
+
if (!threadUris.has(childUri)) {
|
|
189
|
+
threadUris.add(childUri);
|
|
190
|
+
queue.push(childUri);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return posts.filter((post) => threadUris.has(String(post.uri)));
|
|
196
|
+
};
|
package/src/cli/view.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Args, Command } from "@effect/cli";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { StoreName } from "../domain/primitives.js";
|
|
4
|
+
import { DerivationValidator } from "../services/derivation-validator.js";
|
|
5
|
+
import { writeJson } from "./output.js";
|
|
6
|
+
import { withExamples } from "./help.js";
|
|
7
|
+
import { threadCommand } from "./view-thread.js";
|
|
8
|
+
|
|
9
|
+
const viewArg = Args.text({ name: "view" }).pipe(
|
|
10
|
+
Args.withSchema(StoreName),
|
|
11
|
+
Args.withDescription("Derived view store name")
|
|
12
|
+
);
|
|
13
|
+
const sourceArg = Args.text({ name: "source" }).pipe(
|
|
14
|
+
Args.withSchema(StoreName),
|
|
15
|
+
Args.withDescription("Source store name")
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const statusCommand = Command.make(
|
|
19
|
+
"status",
|
|
20
|
+
{ view: viewArg, source: sourceArg },
|
|
21
|
+
({ view, source }) =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const validator = yield* DerivationValidator;
|
|
24
|
+
const isStale = yield* validator.isStale(view, source);
|
|
25
|
+
|
|
26
|
+
yield* writeJson({
|
|
27
|
+
view,
|
|
28
|
+
source,
|
|
29
|
+
status: isStale ? "stale" : "ready"
|
|
30
|
+
});
|
|
31
|
+
})
|
|
32
|
+
).pipe(
|
|
33
|
+
Command.withDescription(
|
|
34
|
+
withExamples("Check if a derived view is stale relative to its source", [
|
|
35
|
+
"skygent view status derived-store source-store"
|
|
36
|
+
])
|
|
37
|
+
)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const viewCommand = Command.make("view", {}).pipe(
|
|
41
|
+
Command.withSubcommands([statusCommand, threadCommand]),
|
|
42
|
+
Command.withDescription(
|
|
43
|
+
withExamples("View derivation status and metadata", [
|
|
44
|
+
"skygent view status derived-store source-store"
|
|
45
|
+
])
|
|
46
|
+
)
|
|
47
|
+
);
|