@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,11 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
export type CliPreferencesValue = {
|
|
4
|
+
readonly compact: boolean;
|
|
5
|
+
readonly logFormat?: "json" | "human";
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export class CliPreferences extends Context.Tag("@skygent/CliPreferences")<
|
|
9
|
+
CliPreferences,
|
|
10
|
+
CliPreferencesValue
|
|
11
|
+
>() {}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { CliInputError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
type FieldSelector = {
|
|
5
|
+
readonly path: ReadonlyArray<string>;
|
|
6
|
+
readonly wildcard: boolean;
|
|
7
|
+
readonly raw: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const fieldPresets: Record<string, ReadonlyArray<string>> = {
|
|
11
|
+
minimal: ["uri", "author", "text", "createdAt"],
|
|
12
|
+
social: ["uri", "author", "text", "metrics", "hashtags"],
|
|
13
|
+
full: []
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
17
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
|
|
19
|
+
const ensureNonEmpty = (value: string, message: string, cause: unknown) => {
|
|
20
|
+
if (value.length === 0) {
|
|
21
|
+
return Effect.fail(CliInputError.make({ message, cause }));
|
|
22
|
+
}
|
|
23
|
+
return Effect.void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const scalarFieldHeads = new Set([
|
|
27
|
+
"uri",
|
|
28
|
+
"cid",
|
|
29
|
+
"author",
|
|
30
|
+
"authorDid",
|
|
31
|
+
"text",
|
|
32
|
+
"createdAt",
|
|
33
|
+
"hashtags",
|
|
34
|
+
"mentions",
|
|
35
|
+
"mentionDids",
|
|
36
|
+
"links",
|
|
37
|
+
"facets",
|
|
38
|
+
"langs",
|
|
39
|
+
"tags",
|
|
40
|
+
"selfLabels",
|
|
41
|
+
"labels",
|
|
42
|
+
"indexedAt"
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const parseFieldToken = (token: string) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const parts = token.split(".").map((segment) => segment.trim());
|
|
48
|
+
if (parts.length === 0) {
|
|
49
|
+
return yield* CliInputError.make({
|
|
50
|
+
message: "Field token cannot be empty.",
|
|
51
|
+
cause: token
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
yield* ensureNonEmpty(
|
|
56
|
+
part,
|
|
57
|
+
`Invalid field token "${token}".`,
|
|
58
|
+
token
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (parts.length > 1) {
|
|
62
|
+
const head = parts[0] ?? "";
|
|
63
|
+
if (scalarFieldHeads.has(head)) {
|
|
64
|
+
const suggestion =
|
|
65
|
+
head === "author"
|
|
66
|
+
? ' Use "author" or "authorProfile.handle".'
|
|
67
|
+
: " Remove the dot path.";
|
|
68
|
+
return yield* CliInputError.make({
|
|
69
|
+
message: `Field "${token}" is not a valid path. "${head}" is a scalar field.${suggestion}`,
|
|
70
|
+
cause: token
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const wildcardIndex = parts.indexOf("*");
|
|
75
|
+
if (wildcardIndex >= 0 && wildcardIndex !== parts.length - 1) {
|
|
76
|
+
return yield* CliInputError.make({
|
|
77
|
+
message: `Wildcard "*" must be the last segment in "${token}".`,
|
|
78
|
+
cause: token
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const wildcardCount = parts.filter((part) => part === "*").length;
|
|
82
|
+
if (wildcardCount > 1) {
|
|
83
|
+
return yield* CliInputError.make({
|
|
84
|
+
message: `Wildcard "*" can only appear once in "${token}".`,
|
|
85
|
+
cause: token
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const wildcard = wildcardIndex === parts.length - 1;
|
|
89
|
+
const path = wildcard ? parts.slice(0, -1) : parts;
|
|
90
|
+
return { path, wildcard, raw: token } satisfies FieldSelector;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const normalizeTokens = (raw: string) =>
|
|
94
|
+
raw
|
|
95
|
+
.split(",")
|
|
96
|
+
.map((token) => token.trim())
|
|
97
|
+
.filter((token) => token.length > 0);
|
|
98
|
+
|
|
99
|
+
const expandPresets = (tokens: ReadonlyArray<string>) =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const expanded: string[] = [];
|
|
102
|
+
let fullRequested = false;
|
|
103
|
+
|
|
104
|
+
for (const token of tokens) {
|
|
105
|
+
if (token.startsWith("@")) {
|
|
106
|
+
const name = token.slice(1);
|
|
107
|
+
const preset = fieldPresets[name];
|
|
108
|
+
if (!preset) {
|
|
109
|
+
return yield* CliInputError.make({
|
|
110
|
+
message: `Unknown fields preset "${token}".`,
|
|
111
|
+
cause: token
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (name === "full") {
|
|
115
|
+
fullRequested = true;
|
|
116
|
+
} else {
|
|
117
|
+
expanded.push(...preset);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
expanded.push(token);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (fullRequested) {
|
|
125
|
+
if (expanded.length > 0) {
|
|
126
|
+
return yield* CliInputError.make({
|
|
127
|
+
message: "Preset @full cannot be combined with other fields.",
|
|
128
|
+
cause: tokens
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return { kind: "full" as const, tokens: [] as ReadonlyArray<string> };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (expanded.length === 0) {
|
|
135
|
+
return yield* CliInputError.make({
|
|
136
|
+
message: "Fields list cannot be empty.",
|
|
137
|
+
cause: tokens
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { kind: "partial" as const, tokens: expanded };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export const parseFieldSelectors = (
|
|
145
|
+
raw: string
|
|
146
|
+
): Effect.Effect<Option.Option<ReadonlyArray<FieldSelector>>, CliInputError> =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
const tokens = normalizeTokens(raw);
|
|
149
|
+
yield* ensureNonEmpty(raw.trim(), "Fields list cannot be empty.", raw);
|
|
150
|
+
const expanded = yield* expandPresets(tokens);
|
|
151
|
+
if (expanded.kind === "full") {
|
|
152
|
+
return Option.none();
|
|
153
|
+
}
|
|
154
|
+
const selectors = yield* Effect.forEach(
|
|
155
|
+
expanded.tokens,
|
|
156
|
+
(token) => parseFieldToken(token),
|
|
157
|
+
{ discard: false }
|
|
158
|
+
);
|
|
159
|
+
return Option.some(selectors);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export const resolveFieldSelectors = (
|
|
163
|
+
fields: Option.Option<string>,
|
|
164
|
+
compact: boolean
|
|
165
|
+
): Effect.Effect<Option.Option<ReadonlyArray<FieldSelector>>, CliInputError> =>
|
|
166
|
+
Option.match(fields, {
|
|
167
|
+
onNone: () =>
|
|
168
|
+
compact ? parseFieldSelectors("@minimal") : Effect.succeed(Option.none()),
|
|
169
|
+
onSome: (raw) => parseFieldSelectors(raw)
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const getPathValue = (source: unknown, path: ReadonlyArray<string>): unknown => {
|
|
173
|
+
let current: unknown = source;
|
|
174
|
+
for (const segment of path) {
|
|
175
|
+
if (!isObject(current)) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
current = current[segment];
|
|
179
|
+
}
|
|
180
|
+
return current;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const ensureObjectPath = (target: Record<string, unknown>, path: string[]) => {
|
|
184
|
+
let current: Record<string, unknown> = target;
|
|
185
|
+
for (const segment of path) {
|
|
186
|
+
const existing = current[segment];
|
|
187
|
+
if (!isObject(existing)) {
|
|
188
|
+
current[segment] = {};
|
|
189
|
+
}
|
|
190
|
+
current = current[segment] as Record<string, unknown>;
|
|
191
|
+
}
|
|
192
|
+
return current;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const setPathValue = (
|
|
196
|
+
target: Record<string, unknown>,
|
|
197
|
+
path: ReadonlyArray<string>,
|
|
198
|
+
value: unknown
|
|
199
|
+
) => {
|
|
200
|
+
if (path.length === 0) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const head = path.slice(0, -1);
|
|
204
|
+
const tail = path[path.length - 1]!;
|
|
205
|
+
const container = ensureObjectPath(target, head);
|
|
206
|
+
container[tail] = value;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const applySelector = (
|
|
210
|
+
target: Record<string, unknown>,
|
|
211
|
+
source: unknown,
|
|
212
|
+
selector: FieldSelector
|
|
213
|
+
) => {
|
|
214
|
+
if (selector.wildcard) {
|
|
215
|
+
const value = getPathValue(source, selector.path);
|
|
216
|
+
if (value === undefined) return;
|
|
217
|
+
if (Array.isArray(value)) {
|
|
218
|
+
setPathValue(target, selector.path, value);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!isObject(value)) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const [key, child] of Object.entries(value)) {
|
|
225
|
+
if (child !== undefined) {
|
|
226
|
+
setPathValue(target, [...selector.path, key], child);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const value = getPathValue(source, selector.path);
|
|
233
|
+
if (value !== undefined) {
|
|
234
|
+
setPathValue(target, selector.path, value);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const projectFields = (
|
|
239
|
+
source: unknown,
|
|
240
|
+
selectors: ReadonlyArray<FieldSelector>
|
|
241
|
+
): Record<string, unknown> => {
|
|
242
|
+
const target: Record<string, unknown> = {};
|
|
243
|
+
for (const selector of selectors) {
|
|
244
|
+
applySelector(target, source, selector);
|
|
245
|
+
}
|
|
246
|
+
return target;
|
|
247
|
+
};
|
package/src/cli/query.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { Args, Command, Options } from "@effect/cli";
|
|
2
|
+
import { Chunk, Clock, Effect, Option, Ref, Stream } from "effect";
|
|
3
|
+
import * as Doc from "@effect/printer/Doc";
|
|
4
|
+
import { all } from "../domain/filter.js";
|
|
5
|
+
import type { FilterExpr } from "../domain/filter.js";
|
|
6
|
+
import { StoreQuery } from "../domain/events.js";
|
|
7
|
+
import { StoreName } from "../domain/primitives.js";
|
|
8
|
+
import type { Post } from "../domain/post.js";
|
|
9
|
+
import { FilterRuntime } from "../services/filter-runtime.js";
|
|
10
|
+
import { AppConfigService } from "../services/app-config.js";
|
|
11
|
+
import { StoreIndex } from "../services/store-index.js";
|
|
12
|
+
import { renderPostsMarkdown, renderPostsTable } from "../domain/format.js";
|
|
13
|
+
import { renderPostCompact, renderPostCard } from "./doc/post.js";
|
|
14
|
+
import { renderThread } from "./doc/thread.js";
|
|
15
|
+
import { renderPlain, renderAnsi } from "./doc/render.js";
|
|
16
|
+
import { parseOptionalFilterExpr } from "./filter-input.js";
|
|
17
|
+
import { CliOutput, writeJson, writeJsonStream, writeText } from "./output.js";
|
|
18
|
+
import { parseRange } from "./range.js";
|
|
19
|
+
import { storeOptions } from "./store.js";
|
|
20
|
+
import { CliPreferences } from "./preferences.js";
|
|
21
|
+
import { projectFields, resolveFieldSelectors } from "./query-fields.js";
|
|
22
|
+
import { CliInputError } from "./errors.js";
|
|
23
|
+
import { withExamples } from "./help.js";
|
|
24
|
+
import { filterOption, filterJsonOption } from "./shared-options.js";
|
|
25
|
+
import { filterByFlags } from "../typeclass/chunk.js";
|
|
26
|
+
|
|
27
|
+
const storeNameArg = Args.text({ name: "store" }).pipe(
|
|
28
|
+
Args.withSchema(StoreName),
|
|
29
|
+
Args.withDescription("Store name to query")
|
|
30
|
+
);
|
|
31
|
+
const rangeOption = Options.text("range").pipe(
|
|
32
|
+
Options.withDescription("ISO range as <start>..<end>"),
|
|
33
|
+
Options.optional
|
|
34
|
+
);
|
|
35
|
+
const limitOption = Options.integer("limit").pipe(
|
|
36
|
+
Options.withDescription("Maximum number of posts to return"),
|
|
37
|
+
Options.optional
|
|
38
|
+
);
|
|
39
|
+
const scanLimitOption = Options.integer("scan-limit").pipe(
|
|
40
|
+
Options.withDescription("Maximum rows to scan before filtering (advanced)"),
|
|
41
|
+
Options.optional
|
|
42
|
+
);
|
|
43
|
+
const sortOption = Options.choice("sort", ["asc", "desc"]).pipe(
|
|
44
|
+
Options.withDescription("Sort order for results (default: asc)"),
|
|
45
|
+
Options.optional
|
|
46
|
+
);
|
|
47
|
+
const newestFirstOption = Options.boolean("newest-first").pipe(
|
|
48
|
+
Options.withDescription("Sort newest posts first (alias for --sort desc)")
|
|
49
|
+
);
|
|
50
|
+
const formatOption = Options.choice("format", [
|
|
51
|
+
"json",
|
|
52
|
+
"ndjson",
|
|
53
|
+
"markdown",
|
|
54
|
+
"table",
|
|
55
|
+
"compact",
|
|
56
|
+
"card",
|
|
57
|
+
"thread"
|
|
58
|
+
]).pipe(
|
|
59
|
+
Options.optional,
|
|
60
|
+
Options.withDescription("Output format (default: config output format)")
|
|
61
|
+
);
|
|
62
|
+
const ansiOption = Options.boolean("ansi").pipe(
|
|
63
|
+
Options.withDescription("Enable ANSI colors in output")
|
|
64
|
+
);
|
|
65
|
+
const widthOption = Options.integer("width").pipe(
|
|
66
|
+
Options.withDescription("Line width for terminal output"),
|
|
67
|
+
Options.optional
|
|
68
|
+
);
|
|
69
|
+
const fieldsOption = Options.text("fields").pipe(
|
|
70
|
+
Options.withDescription(
|
|
71
|
+
"Comma-separated fields to include (supports dot notation and presets: @minimal, @social, @full). Use author or authorProfile.handle for handles."
|
|
72
|
+
),
|
|
73
|
+
Options.optional
|
|
74
|
+
);
|
|
75
|
+
const progressOption = Options.boolean("progress").pipe(
|
|
76
|
+
Options.withDescription("Show progress for filtered queries")
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const DEFAULT_FILTER_SCAN_LIMIT = 5000;
|
|
80
|
+
|
|
81
|
+
const isAscii = (value: string) => /^[\x00-\x7F]*$/.test(value);
|
|
82
|
+
|
|
83
|
+
const hasUnicodeInsensitiveContains = (expr: FilterExpr): boolean => {
|
|
84
|
+
switch (expr._tag) {
|
|
85
|
+
case "Contains":
|
|
86
|
+
return !expr.caseSensitive && expr.text.length > 0 && !isAscii(expr.text);
|
|
87
|
+
case "And":
|
|
88
|
+
return (
|
|
89
|
+
hasUnicodeInsensitiveContains(expr.left) ||
|
|
90
|
+
hasUnicodeInsensitiveContains(expr.right)
|
|
91
|
+
);
|
|
92
|
+
case "Or":
|
|
93
|
+
return (
|
|
94
|
+
hasUnicodeInsensitiveContains(expr.left) ||
|
|
95
|
+
hasUnicodeInsensitiveContains(expr.right)
|
|
96
|
+
);
|
|
97
|
+
case "Not":
|
|
98
|
+
return hasUnicodeInsensitiveContains(expr.expr);
|
|
99
|
+
default:
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const parseRangeOption = (range: Option.Option<string>) =>
|
|
105
|
+
Option.match(range, {
|
|
106
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
107
|
+
onSome: (raw) => parseRange(raw).pipe(Effect.map(Option.some))
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
export const queryCommand = Command.make(
|
|
112
|
+
"query",
|
|
113
|
+
{
|
|
114
|
+
store: storeNameArg,
|
|
115
|
+
range: rangeOption,
|
|
116
|
+
filter: filterOption,
|
|
117
|
+
filterJson: filterJsonOption,
|
|
118
|
+
limit: limitOption,
|
|
119
|
+
scanLimit: scanLimitOption,
|
|
120
|
+
sort: sortOption,
|
|
121
|
+
newestFirst: newestFirstOption,
|
|
122
|
+
format: formatOption,
|
|
123
|
+
ansi: ansiOption,
|
|
124
|
+
width: widthOption,
|
|
125
|
+
fields: fieldsOption,
|
|
126
|
+
progress: progressOption
|
|
127
|
+
},
|
|
128
|
+
({ store, range, filter, filterJson, limit, scanLimit, sort, newestFirst, format, ansi, width, fields, progress }) =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const appConfig = yield* AppConfigService;
|
|
131
|
+
const index = yield* StoreIndex;
|
|
132
|
+
const runtime = yield* FilterRuntime;
|
|
133
|
+
const output = yield* CliOutput;
|
|
134
|
+
const preferences = yield* CliPreferences;
|
|
135
|
+
const storeRef = yield* storeOptions.loadStoreRef(store);
|
|
136
|
+
const parsedRange = yield* parseRangeOption(range);
|
|
137
|
+
const parsedFilter = yield* parseOptionalFilterExpr(filter, filterJson);
|
|
138
|
+
const expr = Option.getOrElse(parsedFilter, () => all());
|
|
139
|
+
const outputFormat = Option.getOrElse(format, () => appConfig.outputFormat);
|
|
140
|
+
const compact = preferences.compact;
|
|
141
|
+
const selectorsOption = yield* resolveFieldSelectors(fields, compact);
|
|
142
|
+
const project = (post: Post) =>
|
|
143
|
+
Option.match(selectorsOption, {
|
|
144
|
+
onNone: () => post,
|
|
145
|
+
onSome: (selectors) => projectFields(post, selectors)
|
|
146
|
+
});
|
|
147
|
+
if (Option.isSome(selectorsOption) && outputFormat !== "json" && outputFormat !== "ndjson") {
|
|
148
|
+
return yield* CliInputError.make({
|
|
149
|
+
message: "--fields is only supported with json or ndjson output.",
|
|
150
|
+
cause: { format: outputFormat }
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const w = Option.getOrUndefined(width);
|
|
155
|
+
|
|
156
|
+
if (Option.isSome(limit) && limit.value <= 0) {
|
|
157
|
+
return yield* CliInputError.make({
|
|
158
|
+
message: "--limit must be a positive integer.",
|
|
159
|
+
cause: { limit: limit.value }
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (Option.isSome(scanLimit) && scanLimit.value <= 0) {
|
|
163
|
+
return yield* CliInputError.make({
|
|
164
|
+
message: "--scan-limit must be a positive integer.",
|
|
165
|
+
cause: { scanLimit: scanLimit.value }
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const sortValue = Option.getOrUndefined(sort);
|
|
169
|
+
const order =
|
|
170
|
+
newestFirst
|
|
171
|
+
? "desc"
|
|
172
|
+
: sortValue;
|
|
173
|
+
if (newestFirst && sortValue === "asc") {
|
|
174
|
+
return yield* CliInputError.make({
|
|
175
|
+
message: "--newest-first conflicts with --sort asc.",
|
|
176
|
+
cause: { newestFirst, sort: sortValue }
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hasFilter = Option.isSome(parsedFilter);
|
|
181
|
+
if (hasFilter && hasUnicodeInsensitiveContains(expr)) {
|
|
182
|
+
yield* output
|
|
183
|
+
.writeStderr(
|
|
184
|
+
"Warning: Unicode case-insensitive contains filters cannot be pushed down; query may scan in-memory.\n"
|
|
185
|
+
)
|
|
186
|
+
.pipe(Effect.catchAll(() => Effect.void));
|
|
187
|
+
}
|
|
188
|
+
const userLimit = Option.getOrUndefined(limit);
|
|
189
|
+
const userScanLimit = Option.getOrUndefined(scanLimit);
|
|
190
|
+
const defaultScanLimit =
|
|
191
|
+
hasFilter && userScanLimit === undefined
|
|
192
|
+
? Math.max(userLimit !== undefined ? userLimit * 50 : 0, DEFAULT_FILTER_SCAN_LIMIT)
|
|
193
|
+
: undefined;
|
|
194
|
+
const resolvedScanLimit =
|
|
195
|
+
hasFilter
|
|
196
|
+
? userScanLimit ?? defaultScanLimit
|
|
197
|
+
: userScanLimit ?? userLimit;
|
|
198
|
+
if (defaultScanLimit !== undefined) {
|
|
199
|
+
yield* output
|
|
200
|
+
.writeStderr(
|
|
201
|
+
`ℹ️ Scanning up to ${defaultScanLimit} posts (filtered query). Use --scan-limit to scan more.`
|
|
202
|
+
)
|
|
203
|
+
.pipe(Effect.catchAll(() => Effect.void));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
hasFilter &&
|
|
208
|
+
Option.isNone(limit) &&
|
|
209
|
+
(outputFormat === "thread" || outputFormat === "table")
|
|
210
|
+
) {
|
|
211
|
+
yield* output
|
|
212
|
+
.writeStderr(
|
|
213
|
+
"Warning: thread/table output collects all matched posts in memory. Consider adding --limit."
|
|
214
|
+
)
|
|
215
|
+
.pipe(Effect.catchAll(() => Effect.void));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const query = StoreQuery.make({
|
|
219
|
+
range: Option.getOrUndefined(parsedRange),
|
|
220
|
+
filter: Option.getOrUndefined(parsedFilter),
|
|
221
|
+
scanLimit: resolvedScanLimit,
|
|
222
|
+
order
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const baseStream = index.query(storeRef, query);
|
|
226
|
+
const progressEnabled = hasFilter && progress;
|
|
227
|
+
const trackScanLimit = hasFilter && resolvedScanLimit !== undefined;
|
|
228
|
+
const scanRef = trackScanLimit
|
|
229
|
+
? yield* Ref.make({ scanned: 0, matched: 0 })
|
|
230
|
+
: undefined;
|
|
231
|
+
let startTime = 0;
|
|
232
|
+
let progressRef: Ref.Ref<{ scanned: number; matched: number; lastReportAt: number }> | undefined;
|
|
233
|
+
if (progressEnabled) {
|
|
234
|
+
startTime = yield* Clock.currentTimeMillis;
|
|
235
|
+
progressRef = yield* Ref.make({ scanned: 0, matched: 0, lastReportAt: startTime });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const reportProgress =
|
|
239
|
+
progressEnabled && progressRef
|
|
240
|
+
? (scanned: number, matched: number, now: number) =>
|
|
241
|
+
output
|
|
242
|
+
.writeStderr(
|
|
243
|
+
`Query progress: scanned=${scanned} matched=${matched} elapsedMs=${now - startTime}`
|
|
244
|
+
)
|
|
245
|
+
.pipe(Effect.catchAll(() => Effect.void))
|
|
246
|
+
: undefined;
|
|
247
|
+
|
|
248
|
+
const onBatch = (scannedDelta: number, matchedDelta: number) =>
|
|
249
|
+
Effect.gen(function* () {
|
|
250
|
+
if (scanRef) {
|
|
251
|
+
yield* Ref.update(scanRef, (state) => ({
|
|
252
|
+
scanned: state.scanned + scannedDelta,
|
|
253
|
+
matched: state.matched + matchedDelta
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
if (progressEnabled && progressRef && reportProgress) {
|
|
257
|
+
const now = yield* Clock.currentTimeMillis;
|
|
258
|
+
const state = yield* Ref.get(progressRef);
|
|
259
|
+
const scanned = state.scanned + scannedDelta;
|
|
260
|
+
const matched = state.matched + matchedDelta;
|
|
261
|
+
const shouldReport =
|
|
262
|
+
scanned % 1000 === 0 || now - state.lastReportAt >= 1000;
|
|
263
|
+
if (shouldReport) {
|
|
264
|
+
yield* reportProgress(scanned, matched, now);
|
|
265
|
+
}
|
|
266
|
+
yield* Ref.set(progressRef, {
|
|
267
|
+
scanned,
|
|
268
|
+
matched,
|
|
269
|
+
lastReportAt: shouldReport ? now : state.lastReportAt
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const evaluateBatch = hasFilter ? yield* runtime.evaluateBatch(expr) : undefined;
|
|
275
|
+
|
|
276
|
+
const filtered = hasFilter && evaluateBatch
|
|
277
|
+
? baseStream.pipe(
|
|
278
|
+
Stream.grouped(50),
|
|
279
|
+
Stream.mapEffect((batch) =>
|
|
280
|
+
evaluateBatch(batch).pipe(
|
|
281
|
+
Effect.map((flags) => {
|
|
282
|
+
const matched = filterByFlags(batch, flags);
|
|
283
|
+
return {
|
|
284
|
+
matched,
|
|
285
|
+
scanned: Chunk.size(batch),
|
|
286
|
+
matchedCount: Chunk.size(matched)
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
Effect.tap(({ scanned, matchedCount }) => onBatch(scanned, matchedCount)),
|
|
290
|
+
Effect.map(({ matched }) => matched)
|
|
291
|
+
)
|
|
292
|
+
),
|
|
293
|
+
Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk))
|
|
294
|
+
)
|
|
295
|
+
: baseStream;
|
|
296
|
+
|
|
297
|
+
const stream = Option.match(limit, {
|
|
298
|
+
onNone: () => filtered,
|
|
299
|
+
onSome: (value) => filtered.pipe(Stream.take(value))
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const warnIfScanLimitReached = scanRef && resolvedScanLimit !== undefined
|
|
303
|
+
? () =>
|
|
304
|
+
Ref.get(scanRef).pipe(
|
|
305
|
+
Effect.flatMap((state) =>
|
|
306
|
+
state.scanned >= resolvedScanLimit
|
|
307
|
+
? output
|
|
308
|
+
.writeStderr(
|
|
309
|
+
`Warning: scan limit ${resolvedScanLimit} reached. Results may be truncated.\n`
|
|
310
|
+
)
|
|
311
|
+
.pipe(Effect.catchAll(() => Effect.void))
|
|
312
|
+
: Effect.void
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
: () => Effect.void;
|
|
316
|
+
|
|
317
|
+
if (outputFormat === "ndjson") {
|
|
318
|
+
yield* writeJsonStream(stream.pipe(Stream.map(project)));
|
|
319
|
+
yield* warnIfScanLimitReached();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (outputFormat === "json") {
|
|
323
|
+
const writeChunk = (value: string) =>
|
|
324
|
+
Stream.fromIterable([value]).pipe(Stream.run(output.stdout));
|
|
325
|
+
let isFirst = true;
|
|
326
|
+
yield* writeChunk("[");
|
|
327
|
+
yield* Stream.runForEach(stream.pipe(Stream.map(project)), (post) => {
|
|
328
|
+
const json = JSON.stringify(post);
|
|
329
|
+
const prefix = isFirst ? "" : ",\n";
|
|
330
|
+
isFirst = false;
|
|
331
|
+
return writeChunk(`${prefix}${json}`);
|
|
332
|
+
});
|
|
333
|
+
const suffix = isFirst ? "]\n" : "\n]\n";
|
|
334
|
+
yield* writeChunk(suffix);
|
|
335
|
+
yield* warnIfScanLimitReached();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
switch (outputFormat) {
|
|
340
|
+
case "compact": {
|
|
341
|
+
const render = (post: Post) =>
|
|
342
|
+
ansi
|
|
343
|
+
? renderAnsi(renderPostCompact(post), w)
|
|
344
|
+
: renderPlain(renderPostCompact(post), w);
|
|
345
|
+
yield* Stream.runForEach(stream, (post) => writeText(render(post)));
|
|
346
|
+
yield* warnIfScanLimitReached();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
case "card": {
|
|
350
|
+
const rendered = stream.pipe(
|
|
351
|
+
Stream.map((post) => {
|
|
352
|
+
const doc = Doc.vsep(renderPostCard(post));
|
|
353
|
+
return ansi ? renderAnsi(doc, w) : renderPlain(doc, w);
|
|
354
|
+
}),
|
|
355
|
+
Stream.mapAccum(true, (isFirst, text) => {
|
|
356
|
+
const output = isFirst ? text : `\\n${text}`;
|
|
357
|
+
return [false, output] as const;
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
yield* Stream.runForEach(rendered, (text) => writeText(text));
|
|
361
|
+
yield* warnIfScanLimitReached();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const collected = yield* Stream.runCollect(stream);
|
|
367
|
+
const posts = Chunk.toReadonlyArray(collected);
|
|
368
|
+
const projectedPosts = Option.isSome(selectorsOption) ? posts.map(project) : posts;
|
|
369
|
+
yield* warnIfScanLimitReached();
|
|
370
|
+
|
|
371
|
+
switch (outputFormat) {
|
|
372
|
+
case "markdown":
|
|
373
|
+
yield* writeText(renderPostsMarkdown(posts));
|
|
374
|
+
return;
|
|
375
|
+
case "table":
|
|
376
|
+
yield* writeText(renderPostsTable(posts));
|
|
377
|
+
return;
|
|
378
|
+
case "thread": {
|
|
379
|
+
// B3: Warn if query doesn't have thread relationships
|
|
380
|
+
if (!hasFilter) {
|
|
381
|
+
yield* output
|
|
382
|
+
.writeStderr(
|
|
383
|
+
"ℹ️ Query results don't have thread relationships. Posts will display in chronological order.\n"
|
|
384
|
+
)
|
|
385
|
+
.pipe(Effect.catchAll(() => Effect.void));
|
|
386
|
+
}
|
|
387
|
+
const doc = renderThread(
|
|
388
|
+
posts,
|
|
389
|
+
w === undefined ? { compact: false } : { compact: false, lineWidth: w }
|
|
390
|
+
);
|
|
391
|
+
yield* writeText(ansi ? renderAnsi(doc, w) : renderPlain(doc, w));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
default:
|
|
395
|
+
yield* writeJson(projectedPosts);
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
).pipe(
|
|
399
|
+
Command.withDescription(
|
|
400
|
+
withExamples(
|
|
401
|
+
"Query a store with optional range and filter",
|
|
402
|
+
[
|
|
403
|
+
"skygent query my-store --limit 25 --format table",
|
|
404
|
+
"skygent query my-store --range 2024-01-01T00:00:00Z..2024-01-31T00:00:00Z --filter 'hashtag:#ai'",
|
|
405
|
+
"skygent query my-store --format card --ansi",
|
|
406
|
+
"skygent query my-store --format thread --ansi --width 120",
|
|
407
|
+
"skygent query my-store --format compact --limit 50",
|
|
408
|
+
"skygent query my-store --sort desc --limit 25"
|
|
409
|
+
],
|
|
410
|
+
[
|
|
411
|
+
"Tip: use --fields @minimal or --compact to reduce JSON output size."
|
|
412
|
+
]
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
);
|