@mepuka/skygent 0.2.0 → 0.3.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 +269 -31
- package/index.ts +18 -3
- package/package.json +1 -1
- package/src/cli/app.ts +4 -2
- package/src/cli/config.ts +20 -3
- package/src/cli/doc/table-renderers.ts +29 -0
- package/src/cli/doc/thread.ts +2 -4
- package/src/cli/exit-codes.ts +2 -0
- package/src/cli/feed.ts +35 -55
- package/src/cli/filter-dsl.ts +146 -11
- package/src/cli/filter-errors.ts +9 -3
- package/src/cli/filter-help.ts +7 -0
- package/src/cli/filter-input.ts +3 -2
- package/src/cli/filter.ts +84 -4
- package/src/cli/graph.ts +193 -156
- package/src/cli/input.ts +45 -0
- package/src/cli/layers.ts +10 -0
- package/src/cli/logging.ts +8 -0
- package/src/cli/output-render.ts +14 -0
- package/src/cli/pagination.ts +18 -0
- package/src/cli/parse-errors.ts +18 -0
- package/src/cli/pipe.ts +157 -0
- package/src/cli/post.ts +43 -66
- package/src/cli/query.ts +349 -74
- package/src/cli/search.ts +92 -118
- package/src/cli/shared.ts +0 -19
- package/src/cli/store-errors.ts +24 -13
- package/src/cli/store-tree.ts +6 -4
- package/src/cli/store.ts +35 -2
- package/src/cli/stream-merge.ts +105 -0
- package/src/cli/sync-factory.ts +28 -3
- package/src/cli/sync.ts +16 -18
- package/src/cli/thread-options.ts +33 -0
- package/src/cli/time.ts +171 -0
- package/src/cli/view-thread.ts +12 -18
- package/src/cli/watch.ts +61 -19
- package/src/domain/errors.ts +6 -1
- package/src/domain/format.ts +21 -0
- package/src/domain/order.ts +24 -0
- package/src/graph/relationships.ts +129 -0
- package/src/services/jetstream-sync.ts +4 -4
- package/src/services/lineage-store.ts +15 -1
- package/src/services/store-commit.ts +60 -0
- package/src/services/store-manager.ts +69 -2
- package/src/services/store-renamer.ts +286 -0
- package/src/services/store-stats.ts +7 -5
- package/src/services/sync-engine.ts +136 -85
- package/src/services/sync-reporter.ts +3 -1
- package/src/services/sync-settings.ts +24 -0
package/src/cli/sync-factory.ts
CHANGED
|
@@ -4,11 +4,13 @@ import { SyncEngine } from "../services/sync-engine.js";
|
|
|
4
4
|
import { SyncReporter } from "../services/sync-reporter.js";
|
|
5
5
|
import { OutputManager } from "../services/output-manager.js";
|
|
6
6
|
import { ResourceMonitor } from "../services/resource-monitor.js";
|
|
7
|
+
import { StoreIndex } from "../services/store-index.js";
|
|
7
8
|
import { parseFilterExpr } from "./filter-input.js";
|
|
8
9
|
import { CliOutput, writeJson, writeJsonStream } from "./output.js";
|
|
9
10
|
import { storeOptions } from "./store.js";
|
|
10
11
|
import { logInfo, logWarn, makeSyncReporter } from "./logging.js";
|
|
11
|
-
import { parseInterval } from "./interval.js";
|
|
12
|
+
import { parseInterval, parseOptionalDuration } from "./interval.js";
|
|
13
|
+
import { CliInputError } from "./errors.js";
|
|
12
14
|
import type { StoreName } from "../domain/primitives.js";
|
|
13
15
|
|
|
14
16
|
/** Common options shared by sync and watch API-based commands */
|
|
@@ -32,6 +34,7 @@ export const makeSyncCommandBody = (
|
|
|
32
34
|
const monitor = yield* ResourceMonitor;
|
|
33
35
|
const output = yield* CliOutput;
|
|
34
36
|
const outputManager = yield* OutputManager;
|
|
37
|
+
const index = yield* StoreIndex;
|
|
35
38
|
const storeRef = yield* storeOptions.loadStoreRef(input.store);
|
|
36
39
|
const storeConfig = yield* storeOptions.loadStoreConfig(input.store);
|
|
37
40
|
const expr = yield* parseFilterExpr(input.filter, input.filterJson);
|
|
@@ -56,13 +59,16 @@ export const makeSyncCommandBody = (
|
|
|
56
59
|
filters: materialized.filters.map((spec) => spec.name)
|
|
57
60
|
});
|
|
58
61
|
}
|
|
62
|
+
const totalPosts = yield* index.count(storeRef);
|
|
59
63
|
yield* logInfo("Sync complete", { source: sourceName, store: storeRef.name, ...extraLogFields });
|
|
60
|
-
yield* writeJson(result as SyncResult);
|
|
64
|
+
yield* writeJson({ ...(result as SyncResult), totalPosts });
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
/** Common options for watch API-based commands */
|
|
64
68
|
export interface WatchCommandInput extends CommonCommandInput {
|
|
65
69
|
readonly interval: Option.Option<string>;
|
|
70
|
+
readonly maxCycles: Option.Option<number>;
|
|
71
|
+
readonly until: Option.Option<string>;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** Build the command body for a watch command (timeline, feed, notifications). */
|
|
@@ -82,6 +88,19 @@ export const makeWatchCommandBody = (
|
|
|
82
88
|
const basePolicy = storeConfig.syncPolicy ?? "dedupe";
|
|
83
89
|
const policy = input.refresh ? "refresh" : basePolicy;
|
|
84
90
|
const parsedInterval = yield* parseInterval(input.interval);
|
|
91
|
+
const parsedUntil = yield* parseOptionalDuration(input.until);
|
|
92
|
+
const parsedMaxCycles = yield* Option.match(input.maxCycles, {
|
|
93
|
+
onNone: () => Effect.succeed(Option.none<number>()),
|
|
94
|
+
onSome: (value) =>
|
|
95
|
+
value <= 0
|
|
96
|
+
? Effect.fail(
|
|
97
|
+
CliInputError.make({
|
|
98
|
+
message: "--max-cycles must be a positive integer.",
|
|
99
|
+
cause: { maxCycles: value }
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
: Effect.succeed(Option.some(value))
|
|
103
|
+
});
|
|
85
104
|
yield* logInfo("Starting watch", { source: sourceName, store: storeRef.name, ...extraLogFields });
|
|
86
105
|
if (policy === "refresh") {
|
|
87
106
|
yield* logWarn("Refresh mode updates existing posts and may grow the event log.", {
|
|
@@ -89,7 +108,7 @@ export const makeWatchCommandBody = (
|
|
|
89
108
|
store: storeRef.name
|
|
90
109
|
});
|
|
91
110
|
}
|
|
92
|
-
|
|
111
|
+
let stream = sync
|
|
93
112
|
.watch(
|
|
94
113
|
WatchConfig.make({
|
|
95
114
|
source: makeDataSource(),
|
|
@@ -103,5 +122,11 @@ export const makeWatchCommandBody = (
|
|
|
103
122
|
Stream.map((event) => event.result),
|
|
104
123
|
Stream.provideService(SyncReporter, makeSyncReporter(input.quiet, monitor, output))
|
|
105
124
|
);
|
|
125
|
+
if (Option.isSome(parsedMaxCycles)) {
|
|
126
|
+
stream = stream.pipe(Stream.take(parsedMaxCycles.value));
|
|
127
|
+
}
|
|
128
|
+
if (Option.isSome(parsedUntil)) {
|
|
129
|
+
stream = stream.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)));
|
|
130
|
+
}
|
|
106
131
|
yield* writeJsonStream(stream);
|
|
107
132
|
});
|
package/src/cli/sync.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { logInfo, makeSyncReporter } from "./logging.js";
|
|
|
9
9
|
import { SyncReporter } from "../services/sync-reporter.js";
|
|
10
10
|
import { ResourceMonitor } from "../services/resource-monitor.js";
|
|
11
11
|
import { OutputManager } from "../services/output-manager.js";
|
|
12
|
+
import { StoreIndex } from "../services/store-index.js";
|
|
12
13
|
import { CliOutput, writeJson } from "./output.js";
|
|
13
14
|
import { parseFilterExpr } from "./filter-input.js";
|
|
14
15
|
import { withExamples } from "./help.js";
|
|
@@ -33,9 +34,13 @@ import {
|
|
|
33
34
|
strictOption,
|
|
34
35
|
maxErrorsOption,
|
|
35
36
|
parseMaxErrors,
|
|
36
|
-
parseLimit
|
|
37
|
-
parseBoundedIntOption
|
|
37
|
+
parseLimit
|
|
38
38
|
} from "./shared-options.js";
|
|
39
|
+
import {
|
|
40
|
+
depthOption as threadDepthOption,
|
|
41
|
+
parentHeightOption as threadParentHeightOption,
|
|
42
|
+
parseThreadDepth
|
|
43
|
+
} from "./thread-options.js";
|
|
39
44
|
|
|
40
45
|
const limitOption = Options.integer("limit").pipe(
|
|
41
46
|
Options.withDescription("Maximum number of Jetstream events to process"),
|
|
@@ -45,13 +50,11 @@ const durationOption = Options.text("duration").pipe(
|
|
|
45
50
|
Options.withDescription("Stop after a duration (e.g. \"2 minutes\")"),
|
|
46
51
|
Options.optional
|
|
47
52
|
);
|
|
48
|
-
const depthOption =
|
|
49
|
-
|
|
50
|
-
Options.optional
|
|
53
|
+
const depthOption = threadDepthOption(
|
|
54
|
+
"Thread reply depth to include (0-1000, default 6)"
|
|
51
55
|
);
|
|
52
|
-
const parentHeightOption =
|
|
53
|
-
|
|
54
|
-
Options.optional
|
|
56
|
+
const parentHeightOption = threadParentHeightOption(
|
|
57
|
+
"Thread parent height to include (0-1000, default 80)"
|
|
55
58
|
);
|
|
56
59
|
|
|
57
60
|
const parseDuration = (value: Option.Option<string>) =>
|
|
@@ -202,15 +205,8 @@ const threadCommand = Command.make(
|
|
|
202
205
|
},
|
|
203
206
|
({ uri, depth, parentHeight, filter, filterJson, store, quiet, refresh }) =>
|
|
204
207
|
Effect.gen(function* () {
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
parentHeight,
|
|
208
|
-
"parent-height",
|
|
209
|
-
0,
|
|
210
|
-
1000
|
|
211
|
-
);
|
|
212
|
-
const depthValue = Option.getOrUndefined(parsedDepth);
|
|
213
|
-
const parentHeightValue = Option.getOrUndefined(parsedParentHeight);
|
|
208
|
+
const { depth: depthValue, parentHeight: parentHeightValue } =
|
|
209
|
+
yield* parseThreadDepth(depth, parentHeight);
|
|
214
210
|
const source = DataSource.thread(uri, {
|
|
215
211
|
...(depthValue !== undefined ? { depth: depthValue } : {}),
|
|
216
212
|
...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
|
|
@@ -273,6 +269,7 @@ const jetstreamCommand = Command.make(
|
|
|
273
269
|
const monitor = yield* ResourceMonitor;
|
|
274
270
|
const output = yield* CliOutput;
|
|
275
271
|
const outputManager = yield* OutputManager;
|
|
272
|
+
const index = yield* StoreIndex;
|
|
276
273
|
const storeRef = yield* storeOptions.loadStoreRef(store);
|
|
277
274
|
const expr = yield* parseFilterExpr(filter, filterJson);
|
|
278
275
|
const filterHash = filterExprSignature(expr);
|
|
@@ -332,8 +329,9 @@ const jetstreamCommand = Command.make(
|
|
|
332
329
|
filters: materialized.filters.map((spec) => spec.name)
|
|
333
330
|
});
|
|
334
331
|
}
|
|
332
|
+
const totalPosts = yield* index.count(storeRef);
|
|
335
333
|
yield* logInfo("Sync complete", { source: "jetstream", store: storeRef.name });
|
|
336
|
-
yield* writeJson(result as SyncResult);
|
|
334
|
+
yield* writeJson({ ...(result as SyncResult), totalPosts });
|
|
337
335
|
})
|
|
338
336
|
).pipe(
|
|
339
337
|
Command.withDescription(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Options } from "@effect/cli";
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
import { parseBoundedIntOption } from "./shared-options.js";
|
|
4
|
+
|
|
5
|
+
export const depthOption = (description: string) =>
|
|
6
|
+
Options.integer("depth").pipe(
|
|
7
|
+
Options.withDescription(description),
|
|
8
|
+
Options.optional
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export const parentHeightOption = (description: string) =>
|
|
12
|
+
Options.integer("parent-height").pipe(
|
|
13
|
+
Options.withDescription(description),
|
|
14
|
+
Options.optional
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const parseThreadDepth = (
|
|
18
|
+
depth: Option.Option<number>,
|
|
19
|
+
parentHeight: Option.Option<number>
|
|
20
|
+
) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const parsedDepth = yield* parseBoundedIntOption(depth, "depth", 0, 1000);
|
|
23
|
+
const parsedParentHeight = yield* parseBoundedIntOption(
|
|
24
|
+
parentHeight,
|
|
25
|
+
"parent-height",
|
|
26
|
+
0,
|
|
27
|
+
1000
|
|
28
|
+
);
|
|
29
|
+
return {
|
|
30
|
+
depth: Option.getOrUndefined(parsedDepth),
|
|
31
|
+
parentHeight: Option.getOrUndefined(parsedParentHeight)
|
|
32
|
+
};
|
|
33
|
+
});
|
package/src/cli/time.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Duration, Effect, Schema } from "effect";
|
|
2
|
+
import { Timestamp } from "../domain/primitives.js";
|
|
3
|
+
import { CliInputError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
export type TimeParseError = (message: string, cause?: unknown) => CliInputError;
|
|
6
|
+
|
|
7
|
+
type TimeParseOptions = {
|
|
8
|
+
readonly label?: string;
|
|
9
|
+
readonly onError?: TimeParseError;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const defaultError: TimeParseError = (message, cause) =>
|
|
13
|
+
CliInputError.make({ message, cause });
|
|
14
|
+
|
|
15
|
+
const compactDurationPattern = /^(-?\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/i;
|
|
16
|
+
const compactDurationUnits: Record<string, number> = {
|
|
17
|
+
ms: 1,
|
|
18
|
+
s: 1000,
|
|
19
|
+
m: 60_000,
|
|
20
|
+
h: 3_600_000,
|
|
21
|
+
d: 86_400_000,
|
|
22
|
+
w: 604_800_000
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const parseCompactDurationMillis = (raw: string): number | undefined => {
|
|
26
|
+
const match = raw.match(compactDurationPattern);
|
|
27
|
+
if (!match) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const value = Number.parseFloat(match[1] ?? "");
|
|
31
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
32
|
+
const multiplier = compactDurationUnits[unit];
|
|
33
|
+
if (!Number.isFinite(value) || multiplier === undefined) {
|
|
34
|
+
return Number.NaN;
|
|
35
|
+
}
|
|
36
|
+
return value * multiplier;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const toUtcStartOfDay = (date: Date) =>
|
|
40
|
+
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
41
|
+
|
|
42
|
+
const parseDateOnly = (raw: string): Date | undefined => {
|
|
43
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
|
44
|
+
if (!match) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const year = Number.parseInt(match[1] ?? "", 10);
|
|
48
|
+
const month = Number.parseInt(match[2] ?? "", 10);
|
|
49
|
+
const day = Number.parseInt(match[3] ?? "", 10);
|
|
50
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
54
|
+
if (
|
|
55
|
+
date.getUTCFullYear() !== year ||
|
|
56
|
+
date.getUTCMonth() !== month - 1 ||
|
|
57
|
+
date.getUTCDate() !== day
|
|
58
|
+
) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return date;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const looksLikeDate = (raw: string) => /^\d{4}-\d{2}-\d{2}/.test(raw);
|
|
65
|
+
|
|
66
|
+
const hasExplicitTimezone = (raw: string) => /([zZ]|[+-]\d{2}:\d{2})$/.test(raw);
|
|
67
|
+
|
|
68
|
+
const makeErrorFactory = (options?: TimeParseOptions): TimeParseError => {
|
|
69
|
+
const base = options?.onError ?? defaultError;
|
|
70
|
+
const label = options?.label;
|
|
71
|
+
if (!label) {
|
|
72
|
+
return base;
|
|
73
|
+
}
|
|
74
|
+
return (message, cause) => base(`${label}: ${message}`, cause);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const parseDurationInput = (
|
|
78
|
+
raw: string,
|
|
79
|
+
options?: TimeParseOptions
|
|
80
|
+
): Effect.Effect<Duration.Duration, CliInputError> =>
|
|
81
|
+
Effect.suspend(() => {
|
|
82
|
+
const onError = makeErrorFactory(options);
|
|
83
|
+
const trimmed = raw.trim();
|
|
84
|
+
if (trimmed.length === 0) {
|
|
85
|
+
return Effect.fail(onError("Duration cannot be empty."));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const compactMillis = parseCompactDurationMillis(trimmed);
|
|
89
|
+
if (compactMillis !== undefined) {
|
|
90
|
+
if (!Number.isFinite(compactMillis)) {
|
|
91
|
+
return Effect.fail(onError("Duration must be a finite number."));
|
|
92
|
+
}
|
|
93
|
+
if (compactMillis < 0) {
|
|
94
|
+
return Effect.fail(onError("Duration must be non-negative."));
|
|
95
|
+
}
|
|
96
|
+
return Effect.succeed(Duration.millis(compactMillis));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return Effect.try({
|
|
100
|
+
try: () => Duration.decode(trimmed as Duration.DurationInput),
|
|
101
|
+
catch: (cause) =>
|
|
102
|
+
onError(
|
|
103
|
+
`Invalid duration "${raw}". Use formats like "30 seconds", "500 millis", or "1.5h".`,
|
|
104
|
+
cause
|
|
105
|
+
)
|
|
106
|
+
}).pipe(
|
|
107
|
+
Effect.flatMap((duration) => {
|
|
108
|
+
if (!Duration.isFinite(duration)) {
|
|
109
|
+
return Effect.fail(onError("Duration must be finite."));
|
|
110
|
+
}
|
|
111
|
+
if (Duration.toMillis(duration) < 0) {
|
|
112
|
+
return Effect.fail(onError("Duration must be non-negative."));
|
|
113
|
+
}
|
|
114
|
+
return Effect.succeed(duration);
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const parseTimeInput = (
|
|
120
|
+
raw: string,
|
|
121
|
+
now: Date,
|
|
122
|
+
options?: TimeParseOptions
|
|
123
|
+
): Effect.Effect<Date, CliInputError> =>
|
|
124
|
+
Effect.suspend(() => {
|
|
125
|
+
const onError = makeErrorFactory(options);
|
|
126
|
+
const trimmed = raw.trim();
|
|
127
|
+
if (trimmed.length === 0) {
|
|
128
|
+
return Effect.fail(onError("Time value cannot be empty."));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const lower = trimmed.toLowerCase();
|
|
132
|
+
if (lower === "now") {
|
|
133
|
+
return Effect.succeed(new Date(now.getTime()));
|
|
134
|
+
}
|
|
135
|
+
if (lower === "today") {
|
|
136
|
+
return Effect.succeed(toUtcStartOfDay(now));
|
|
137
|
+
}
|
|
138
|
+
if (lower === "yesterday") {
|
|
139
|
+
return Effect.succeed(new Date(toUtcStartOfDay(now).getTime() - 86_400_000));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const dateOnly = parseDateOnly(trimmed);
|
|
143
|
+
if (dateOnly) {
|
|
144
|
+
return Effect.succeed(dateOnly);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (looksLikeDate(trimmed)) {
|
|
148
|
+
if (/[Tt]/.test(trimmed) && !hasExplicitTimezone(trimmed)) {
|
|
149
|
+
return Effect.fail(
|
|
150
|
+
onError(
|
|
151
|
+
"Timestamp must include a timezone (e.g. 2026-01-01T00:00:00Z)."
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return Schema.decodeUnknown(Timestamp)(trimmed).pipe(
|
|
156
|
+
Effect.mapError((cause) =>
|
|
157
|
+
onError(
|
|
158
|
+
`Invalid timestamp "${raw}". Expected ISO 8601 with timezone (e.g. 2026-01-01T00:00:00Z).`,
|
|
159
|
+
cause
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return parseDurationInput(trimmed, options).pipe(
|
|
166
|
+
Effect.map((duration) => {
|
|
167
|
+
const millis = Duration.toMillis(duration);
|
|
168
|
+
return new Date(now.getTime() - millis);
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
});
|
package/src/cli/view-thread.ts
CHANGED
|
@@ -16,7 +16,11 @@ import { storeOptions } from "./store.js";
|
|
|
16
16
|
import { withExamples } from "./help.js";
|
|
17
17
|
import { CliInputError } from "./errors.js";
|
|
18
18
|
import { formatSchemaError } from "./shared.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
depthOption as threadDepthOption,
|
|
21
|
+
parentHeightOption as threadParentHeightOption,
|
|
22
|
+
parseThreadDepth
|
|
23
|
+
} from "./thread-options.js";
|
|
20
24
|
import { textJsonFormats } from "./output-format.js";
|
|
21
25
|
|
|
22
26
|
const uriArg = Args.text({ name: "uri" }).pipe(
|
|
@@ -47,14 +51,9 @@ const formatOption = Options.choice("format", textJsonFormats).pipe(
|
|
|
47
51
|
Options.optional
|
|
48
52
|
);
|
|
49
53
|
|
|
50
|
-
const depthOption =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const parentHeightOption = Options.integer("parent-height").pipe(
|
|
56
|
-
Options.withDescription("Parent height (API only, default: 80)"),
|
|
57
|
-
Options.optional
|
|
54
|
+
const depthOption = threadDepthOption("Reply depth (API only, default: 6)");
|
|
55
|
+
const parentHeightOption = threadParentHeightOption(
|
|
56
|
+
"Parent height (API only, default: 80)"
|
|
58
57
|
);
|
|
59
58
|
|
|
60
59
|
export const threadCommand = Command.make(
|
|
@@ -73,15 +72,10 @@ export const threadCommand = Command.make(
|
|
|
73
72
|
Effect.gen(function* () {
|
|
74
73
|
const outputFormat = Option.getOrElse(format, () => "text" as const);
|
|
75
74
|
const w = Option.getOrUndefined(width);
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
0,
|
|
81
|
-
1000
|
|
82
|
-
);
|
|
83
|
-
const d = Option.getOrElse(parsedDepth, () => 6);
|
|
84
|
-
const ph = Option.getOrElse(parsedParentHeight, () => 80);
|
|
75
|
+
const { depth: depthValue, parentHeight: parentHeightValue } =
|
|
76
|
+
yield* parseThreadDepth(depth, parentHeight);
|
|
77
|
+
const d = depthValue ?? 6;
|
|
78
|
+
const ph = parentHeightValue ?? 80;
|
|
85
79
|
|
|
86
80
|
let posts: ReadonlyArray<Post>;
|
|
87
81
|
|
package/src/cli/watch.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { ResourceMonitor } from "../services/resource-monitor.js";
|
|
|
13
13
|
import { withExamples } from "./help.js";
|
|
14
14
|
import { buildJetstreamSelection, jetstreamOptions } from "./jetstream.js";
|
|
15
15
|
import { makeWatchCommandBody } from "./sync-factory.js";
|
|
16
|
+
import { parseOptionalDuration } from "./interval.js";
|
|
17
|
+
import { CliInputError } from "./errors.js";
|
|
16
18
|
import {
|
|
17
19
|
feedUriArg,
|
|
18
20
|
listUriArg,
|
|
@@ -30,9 +32,13 @@ import {
|
|
|
30
32
|
refreshOption,
|
|
31
33
|
strictOption,
|
|
32
34
|
maxErrorsOption,
|
|
33
|
-
parseMaxErrors
|
|
34
|
-
parseBoundedIntOption
|
|
35
|
+
parseMaxErrors
|
|
35
36
|
} from "./shared-options.js";
|
|
37
|
+
import {
|
|
38
|
+
depthOption as threadDepthOption,
|
|
39
|
+
parentHeightOption as threadParentHeightOption,
|
|
40
|
+
parseThreadDepth
|
|
41
|
+
} from "./thread-options.js";
|
|
36
42
|
|
|
37
43
|
const intervalOption = Options.text("interval").pipe(
|
|
38
44
|
Options.withDescription(
|
|
@@ -40,14 +46,20 @@ const intervalOption = Options.text("interval").pipe(
|
|
|
40
46
|
),
|
|
41
47
|
Options.optional
|
|
42
48
|
);
|
|
43
|
-
const
|
|
44
|
-
Options.withDescription("
|
|
49
|
+
const maxCyclesOption = Options.integer("max-cycles").pipe(
|
|
50
|
+
Options.withDescription("Stop after N watch cycles"),
|
|
45
51
|
Options.optional
|
|
46
52
|
);
|
|
47
|
-
const
|
|
48
|
-
Options.withDescription("
|
|
53
|
+
const untilOption = Options.text("until").pipe(
|
|
54
|
+
Options.withDescription("Stop after a duration (e.g. \"10 minutes\")"),
|
|
49
55
|
Options.optional
|
|
50
56
|
);
|
|
57
|
+
const depthOption = threadDepthOption(
|
|
58
|
+
"Thread reply depth to include (0-1000, default 6)"
|
|
59
|
+
);
|
|
60
|
+
const parentHeightOption = threadParentHeightOption(
|
|
61
|
+
"Thread parent height to include (0-1000, default 80)"
|
|
62
|
+
);
|
|
51
63
|
|
|
52
64
|
const timelineCommand = Command.make(
|
|
53
65
|
"timeline",
|
|
@@ -56,6 +68,8 @@ const timelineCommand = Command.make(
|
|
|
56
68
|
filter: filterOption,
|
|
57
69
|
filterJson: filterJsonOption,
|
|
58
70
|
interval: intervalOption,
|
|
71
|
+
maxCycles: maxCyclesOption,
|
|
72
|
+
until: untilOption,
|
|
59
73
|
quiet: quietOption,
|
|
60
74
|
refresh: refreshOption
|
|
61
75
|
},
|
|
@@ -81,6 +95,8 @@ const feedCommand = Command.make(
|
|
|
81
95
|
filter: filterOption,
|
|
82
96
|
filterJson: filterJsonOption,
|
|
83
97
|
interval: intervalOption,
|
|
98
|
+
maxCycles: maxCyclesOption,
|
|
99
|
+
until: untilOption,
|
|
84
100
|
quiet: quietOption,
|
|
85
101
|
refresh: refreshOption
|
|
86
102
|
},
|
|
@@ -105,6 +121,8 @@ const listCommand = Command.make(
|
|
|
105
121
|
filter: filterOption,
|
|
106
122
|
filterJson: filterJsonOption,
|
|
107
123
|
interval: intervalOption,
|
|
124
|
+
maxCycles: maxCyclesOption,
|
|
125
|
+
until: untilOption,
|
|
108
126
|
quiet: quietOption,
|
|
109
127
|
refresh: refreshOption
|
|
110
128
|
},
|
|
@@ -128,6 +146,8 @@ const notificationsCommand = Command.make(
|
|
|
128
146
|
filter: filterOption,
|
|
129
147
|
filterJson: filterJsonOption,
|
|
130
148
|
interval: intervalOption,
|
|
149
|
+
maxCycles: maxCyclesOption,
|
|
150
|
+
until: untilOption,
|
|
131
151
|
quiet: quietOption,
|
|
132
152
|
refresh: refreshOption
|
|
133
153
|
},
|
|
@@ -152,10 +172,12 @@ const authorCommand = Command.make(
|
|
|
152
172
|
postFilter: postFilterOption,
|
|
153
173
|
postFilterJson: postFilterJsonOption,
|
|
154
174
|
interval: intervalOption,
|
|
175
|
+
maxCycles: maxCyclesOption,
|
|
176
|
+
until: untilOption,
|
|
155
177
|
quiet: quietOption,
|
|
156
178
|
refresh: refreshOption
|
|
157
179
|
},
|
|
158
|
-
({ actor, filter, includePins, postFilter, postFilterJson, interval, store, quiet, refresh }) =>
|
|
180
|
+
({ actor, filter, includePins, postFilter, postFilterJson, interval, maxCycles, until, store, quiet, refresh }) =>
|
|
159
181
|
Effect.gen(function* () {
|
|
160
182
|
const resolvedActor = yield* decodeActor(actor);
|
|
161
183
|
const apiFilter = Option.getOrUndefined(filter);
|
|
@@ -173,6 +195,8 @@ const authorCommand = Command.make(
|
|
|
173
195
|
filter: postFilter,
|
|
174
196
|
filterJson: postFilterJson,
|
|
175
197
|
interval,
|
|
198
|
+
maxCycles,
|
|
199
|
+
until,
|
|
176
200
|
quiet,
|
|
177
201
|
refresh
|
|
178
202
|
});
|
|
@@ -200,20 +224,15 @@ const threadCommand = Command.make(
|
|
|
200
224
|
filter: filterOption,
|
|
201
225
|
filterJson: filterJsonOption,
|
|
202
226
|
interval: intervalOption,
|
|
227
|
+
maxCycles: maxCyclesOption,
|
|
228
|
+
until: untilOption,
|
|
203
229
|
quiet: quietOption,
|
|
204
230
|
refresh: refreshOption
|
|
205
231
|
},
|
|
206
|
-
({ uri, depth, parentHeight, filter, filterJson, interval, store, quiet, refresh }) =>
|
|
232
|
+
({ uri, depth, parentHeight, filter, filterJson, interval, maxCycles, until, store, quiet, refresh }) =>
|
|
207
233
|
Effect.gen(function* () {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
parentHeight,
|
|
211
|
-
"parent-height",
|
|
212
|
-
0,
|
|
213
|
-
1000
|
|
214
|
-
);
|
|
215
|
-
const depthValue = Option.getOrUndefined(parsedDepth);
|
|
216
|
-
const parentHeightValue = Option.getOrUndefined(parsedParentHeight);
|
|
234
|
+
const { depth: depthValue, parentHeight: parentHeightValue } =
|
|
235
|
+
yield* parseThreadDepth(depth, parentHeight);
|
|
217
236
|
const source = DataSource.thread(uri, {
|
|
218
237
|
...(depthValue !== undefined ? { depth: depthValue } : {}),
|
|
219
238
|
...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
|
|
@@ -223,7 +242,7 @@ const threadCommand = Command.make(
|
|
|
223
242
|
...(depthValue !== undefined ? { depth: depthValue } : {}),
|
|
224
243
|
...(parentHeightValue !== undefined ? { parentHeight: parentHeightValue } : {})
|
|
225
244
|
});
|
|
226
|
-
return yield* run({ store, filter, filterJson, interval, quiet, refresh });
|
|
245
|
+
return yield* run({ store, filter, filterJson, interval, maxCycles, until, quiet, refresh });
|
|
227
246
|
})
|
|
228
247
|
).pipe(
|
|
229
248
|
Command.withDescription(
|
|
@@ -251,6 +270,8 @@ const jetstreamCommand = Command.make(
|
|
|
251
270
|
cursor: jetstreamOptions.cursor,
|
|
252
271
|
compress: jetstreamOptions.compress,
|
|
253
272
|
maxMessageSize: jetstreamOptions.maxMessageSize,
|
|
273
|
+
maxCycles: maxCyclesOption,
|
|
274
|
+
until: untilOption,
|
|
254
275
|
strict: strictOption,
|
|
255
276
|
maxErrors: maxErrorsOption
|
|
256
277
|
},
|
|
@@ -265,6 +286,8 @@ const jetstreamCommand = Command.make(
|
|
|
265
286
|
cursor,
|
|
266
287
|
compress,
|
|
267
288
|
maxMessageSize,
|
|
289
|
+
maxCycles,
|
|
290
|
+
until,
|
|
268
291
|
strict,
|
|
269
292
|
maxErrors
|
|
270
293
|
}) =>
|
|
@@ -287,6 +310,19 @@ const jetstreamCommand = Command.make(
|
|
|
287
310
|
filterHash
|
|
288
311
|
);
|
|
289
312
|
const parsedMaxErrors = yield* parseMaxErrors(maxErrors);
|
|
313
|
+
const parsedUntil = yield* parseOptionalDuration(until);
|
|
314
|
+
const parsedMaxCycles = yield* Option.match(maxCycles, {
|
|
315
|
+
onNone: () => Effect.succeed(Option.none<number>()),
|
|
316
|
+
onSome: (value) =>
|
|
317
|
+
value <= 0
|
|
318
|
+
? Effect.fail(
|
|
319
|
+
CliInputError.make({
|
|
320
|
+
message: "--max-cycles must be a positive integer.",
|
|
321
|
+
cause: { maxCycles: value }
|
|
322
|
+
})
|
|
323
|
+
)
|
|
324
|
+
: Effect.succeed(Option.some(value))
|
|
325
|
+
});
|
|
290
326
|
const engineLayer = JetstreamSyncEngine.layer.pipe(
|
|
291
327
|
Layer.provideMerge(Jetstream.live(selection.config))
|
|
292
328
|
);
|
|
@@ -310,7 +346,13 @@ const jetstreamCommand = Command.make(
|
|
|
310
346
|
makeSyncReporter(quiet, monitor, output)
|
|
311
347
|
)
|
|
312
348
|
);
|
|
313
|
-
|
|
349
|
+
const limited = Option.isSome(parsedMaxCycles)
|
|
350
|
+
? outputStream.pipe(Stream.take(parsedMaxCycles.value))
|
|
351
|
+
: outputStream;
|
|
352
|
+
const timed = Option.isSome(parsedUntil)
|
|
353
|
+
? limited.pipe(Stream.interruptWhen(Effect.sleep(parsedUntil.value)))
|
|
354
|
+
: limited;
|
|
355
|
+
return yield* writeJsonStream(timed);
|
|
314
356
|
}).pipe(Effect.provide(engineLayer));
|
|
315
357
|
})
|
|
316
358
|
).pipe(
|
package/src/domain/errors.ts
CHANGED
|
@@ -42,6 +42,11 @@ export class StoreNotFound extends Schema.TaggedError<StoreNotFound>()(
|
|
|
42
42
|
{ name: StoreName }
|
|
43
43
|
) {}
|
|
44
44
|
|
|
45
|
+
export class StoreAlreadyExists extends Schema.TaggedError<StoreAlreadyExists>()(
|
|
46
|
+
"StoreAlreadyExists",
|
|
47
|
+
{ name: StoreName }
|
|
48
|
+
) {}
|
|
49
|
+
|
|
45
50
|
export class StoreIoError extends Schema.TaggedError<StoreIoError>()(
|
|
46
51
|
"StoreIoError",
|
|
47
52
|
{ path: StorePath, cause: Schema.Unknown }
|
|
@@ -68,4 +73,4 @@ export class FilterLibraryError extends Schema.TaggedError<FilterLibraryError>()
|
|
|
68
73
|
}
|
|
69
74
|
) {}
|
|
70
75
|
|
|
71
|
-
export type StoreError = StoreNotFound | StoreIoError | StoreIndexError;
|
|
76
|
+
export type StoreError = StoreNotFound | StoreAlreadyExists | StoreIoError | StoreIndexError;
|
package/src/domain/format.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Post } from "./post.js";
|
|
|
2
2
|
import { displayWidth, padEndDisplay } from "./text-width.js";
|
|
3
3
|
|
|
4
4
|
const headers = ["Created At", "Author", "Text", "URI"];
|
|
5
|
+
const headersWithStore = ["Store", ...headers];
|
|
5
6
|
const textLimit = 80;
|
|
6
7
|
|
|
7
8
|
export const normalizeWhitespace = (text: string) =>
|
|
@@ -41,6 +42,16 @@ const postToMarkdownRow = (post: Post) => [
|
|
|
41
42
|
post.uri.replace(/\|/g, "\\|")
|
|
42
43
|
];
|
|
43
44
|
|
|
45
|
+
const storePostToRow = (entry: { readonly store: string; readonly post: Post }) => [
|
|
46
|
+
entry.store,
|
|
47
|
+
...postToRow(entry.post)
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const storePostToMarkdownRow = (entry: { readonly store: string; readonly post: Post }) => [
|
|
51
|
+
entry.store,
|
|
52
|
+
...postToMarkdownRow(entry.post)
|
|
53
|
+
];
|
|
54
|
+
|
|
44
55
|
const renderTable = (
|
|
45
56
|
head: ReadonlyArray<string>,
|
|
46
57
|
rows: ReadonlyArray<ReadonlyArray<string>>
|
|
@@ -89,3 +100,13 @@ export const renderPostsTable = (posts: ReadonlyArray<Post>) =>
|
|
|
89
100
|
|
|
90
101
|
export const renderPostsMarkdown = (posts: ReadonlyArray<Post>) =>
|
|
91
102
|
renderMarkdownTable(headers, posts.map(postToMarkdownRow));
|
|
103
|
+
|
|
104
|
+
export const renderStorePostsTable = (
|
|
105
|
+
entries: ReadonlyArray<{ readonly store: string; readonly post: Post }>
|
|
106
|
+
) =>
|
|
107
|
+
renderTable(headersWithStore, entries.map(storePostToRow));
|
|
108
|
+
|
|
109
|
+
export const renderStorePostsMarkdown = (
|
|
110
|
+
entries: ReadonlyArray<{ readonly store: string; readonly post: Post }>
|
|
111
|
+
) =>
|
|
112
|
+
renderMarkdownTable(headersWithStore, entries.map(storePostToMarkdownRow));
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Order } from "effect";
|
|
2
|
+
import type { Post } from "./post.js";
|
|
3
|
+
import type { StoreRef } from "./store.js";
|
|
4
|
+
|
|
5
|
+
export const LocaleStringOrder = Order.make<string>((left, right) => {
|
|
6
|
+
const result = left.localeCompare(right);
|
|
7
|
+
if (result < 0) return -1;
|
|
8
|
+
if (result > 0) return 1;
|
|
9
|
+
return 0;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const PostOrder = Order.mapInput(
|
|
13
|
+
Order.tuple(Order.Date, LocaleStringOrder),
|
|
14
|
+
(post: Post) => [post.createdAt, post.uri] as const
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const StorePostOrder = Order.mapInput(
|
|
18
|
+
Order.tuple(Order.Date, LocaleStringOrder, LocaleStringOrder),
|
|
19
|
+
(entry: { readonly post: Post; readonly store: StoreRef }) =>
|
|
20
|
+
[entry.post.createdAt, entry.post.uri, entry.store.name] as const
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const updatedAtOrder = <A extends { readonly updatedAt: Date }>() =>
|
|
24
|
+
Order.mapInput(Order.Date, (value: A) => value.updatedAt);
|