DEV Community

xiaoxu
xiaoxu

Posted on

Designing a No-Network Dry Run for an Image-Aware Publisher

Designing a No-Network Dry Run for an Image-Aware Publisher

Why this matters

A useful dry run should do more than print “would publish.” It should parse the real article, process the real local images, build the real platform payload, and expose failures before credentials can create a remote artifact.

That requirement gets harder when a Markdown publisher owns images as well as text. The normal path may optimize files, query persistent upload records, contact object storage, replace Markdown URLs, and finally call a publishing API. Simply skipping the last HTTP request leaves several earlier network writes intact.

The focused question is:

How can an image-aware publisher exercise its real transformation path while making storage and platform writes impossible?

I traced and tested a TypeScript publisher that answers with dependency substitution plus an early return: use an in-memory database, use a preview storage provider, process the article normally, then stop before persistent publication state and platform clients are touched.

What I built or tested

I ran the publisher against a generated SVG fixture with all of these flags together:

npm run publish -- fixture/index.md \
  --public \
  --platform devto \
  --dry-run \
  --output-markdown fixture/preview.md
Enter fullscreen mode Exit fullscreen mode

Using --public was deliberate. It exercised construction of a public payload while --dry-run remained the higher-level safety boundary.

The observed result was:

Check Result
Reported mode public
Platform results Empty
Image URL https://preview.invalid/...
Persistent SQLite checksum Unchanged
Local processed-image cache Changed

That last row is important. This implementation provides a no-network dry run, not a zero-side-effect sandbox.

Setup

The experiment used Node.js 22, the repository's TypeScript CLI, a small local SVG, and an existing SQLite publication database whose checksum was recorded before and after the run. It used no production images and no remote requests.

The real DEV/Forem create operation is POST /api/articles; the official API contract uses the published field to choose draft or immediate publication. The current contract is documented in the Forem v1 API reference. A trustworthy dry run must stop before this operation regardless of whether the requested payload says published: true.

The architecture already exposes two interfaces that make this possible:

interface DatabaseProvider {
  findPublishRecord(slug: string): Promise<PublishRecord | null>;
  savePublishRecord(record: PublishRecord): Promise<void>;
  findImage(provider: string, objectKey: string): Promise<ImageRecord | null>;
  saveImage(record: ImageRecord): Promise<void>;
}

interface StorageProvider {
  readonly name: string;
  exists(objectKey: string): Promise<boolean>;
  upload(input: UploadInput): Promise<string>;
  publicUrl(objectKey: string): string;
}
Enter fullscreen mode Exit fullscreen mode

The dry-run path can therefore replace behaviors at the provider boundary instead of sprinkling if (dryRun) around every network call.

Step-by-step walkthrough

1. Choose safe dependencies before processing

Immediately after parsing the article, the publisher selects its database and storage implementations:

const database = options.dryRun
  ? new MemoryDatabaseProvider()
  : createDatabaseProvider(config);

const storage = options.dryRun
  ? createPreviewStorage()
  : createStorageProvider(config);
Enter fullscreen mode Exit fullscreen mode

The in-memory provider implements the same publication and image-record operations as SQLite or MySQL, but its maps disappear when the process ends. Preview storage implements the same object methods as ImageKit or R2 without importing a network client.

Choosing these dependencies at the top is the critical safety property. Every later image operation receives the preview providers through the normal call chain.

2. Process local assets through the real pipeline

Dry run does not bypass article parsing or image replacement. The publisher still resolves local paths, optimizes images, computes content hashes, builds object keys, and replaces Markdown image nodes.

Preview storage intentionally reports that objects do not already exist. Its upload method returns the same URL shape its publicUrl method would produce:

publicUrl(objectKey: string): string {
  return `https://preview.invalid/${encodePath(objectKey)}`;
}

async upload({ objectKey }: { objectKey: string }): Promise<string> {
  return this.publicUrl(objectKey);
}
Enter fullscreen mode Exit fullscreen mode

The reserved .invalid top-level domain makes the artifact visibly non-production. The preview Markdown shows the transformed path without creating an address that could be mistaken for a successful upload.

3. Build the actual publication input

After image replacement, the publisher constructs the same PublishInput used by a real run: title, rendered Markdown, description, tags, canonical URL, cover URL, and the requested published state.

This is why --public --dry-run is a useful test. It proves that public-mode payload construction works while still preventing a platform call.

4. Return before persistent publication and platform work

The decisive guard appears after payload construction and before persistent state lookup:

if (options.dryRun || options.prepareOnly) return output;
Enter fullscreen mode Exit fullscreen mode

Only code after this point loads an existing publication record, calculates platform actions, constructs the DEV.to publisher, and sends a create or update request.

Mermaid diagram 1

The diagram highlights both safety layers: external providers are replaced before transformation, and the platform path is cut off after the payload exists.

5. Write an inspectable artifact

The CLI can write the transformed Markdown to a requested output path. That artifact includes preview image URLs and preserves the article metadata, so a reviewer can inspect exactly what would be passed downstream.

This is a local write, but it is intentional and named by the caller. It is different from a remote publication or durable publication-state change.

What went wrong

My initial mental model was “dry run means no writes.” The experiment disproved that wording.

The persistent SQLite checksum stayed unchanged, and no platform result appeared, but the processed-image cache changed. The reason is visible in control flow: the publisher uses a fixed local cache directory, and image optimization happens before the dry-run early return. Preview storage prevents a remote upload; it does not prevent Sharp or file-copy output.

This distinction matters in CI and agent workflows. A supposedly clean preview can leave an untracked cache object, affect later file counts, or make a workspace appear dirty even though no network write occurred.

There are also boundaries the dry run cannot verify:

  • whether the API key is accepted by DEV.to;
  • whether ImageKit or R2 permissions are valid remotely;
  • how the published page renders on the platform;
  • rate limits, moderation, or remote validation behavior;
  • whether a real create returns a stable public URL.

The repository currently lacks a focused unit test that calls the publication function with dryRun: true. Source tracing and the CLI experiment establish the current behavior, but a regression test would make the no-network boundary durable.

Fix or mitigation

The immediate fix is precise language: promise no storage-provider or publishing-platform network writes, not “no side effects.” Then make local mutations explicit.

A reusable dry-run checklist is:

  1. Select fake or in-memory external dependencies before business logic begins.
  2. Exercise the same parser, validator, image transformer, and payload builder as production.
  3. Use an unmistakably non-production URL domain for preview assets.
  4. Return before persistent publication lookup and every platform client.
  5. Write preview artifacts only to caller-selected paths.
  6. Record or isolate any local cache directory used during transformation.
  7. Test dry run with the most dangerous requested mode, such as --public.
  8. Assert that persistent state is unchanged and platform results are empty.

For a truly clean workspace, inject the cache directory too. A dry run could use a temporary directory that is removed afterward, while real preparation retains the durable cache. That change would preserve transformation fidelity without leaving new files in the repository.

Trade-offs

Dependency substitution adds implementations that must remain behaviorally compatible with production providers. Preview storage can model URL construction and image replacement, but it cannot reproduce authentication failures, latency, provider-specific validation, or eventual consistency.

Running real image processing improves confidence but costs CPU and creates temporary or cached files. Skipping image work would be faster and cleaner, but it would miss broken paths, unsupported formats, optimizer failures, and incorrect Markdown replacement—the failures a useful preview is meant to catch.

An in-memory database avoids persistent state changes, but it also starts empty on every run. It cannot reveal a conflict with an existing remote ID or an unknown publication state. Reconciliation and update behavior need separate state-machine tests.

The design therefore optimizes for payload fidelity and network safety, not complete production simulation.

How I verified it

I recorded the SQLite file checksum before and after a public-mode dry run; it was unchanged. The CLI reported dryRun: true, returned no platform result, and wrote one preview.invalid image reference into the preview artifact.

I also inspected the local cache and found the generated SVG copy, confirming the local-side-effect limitation rather than hiding it. Source tracing tied each observation to the provider selection, image-processing sequence, and early return.

Finally, I checked the official Forem v1 API reference for the actual article-create boundary. The repository's DEV.to client sends that create only after the dry-run return point.

Before publication, the TypeScript check passed and the full Vitest suite passed all 12 tests across seven test files. The article validator also rendered and previewed the Mermaid diagram without a network write.

Conclusion

A trustworthy dry run is not a parallel toy implementation. It is the real transformation pipeline with dangerous dependencies replaced and a hard stop before irreversible work.

For an image-aware publisher, that means using in-memory state, producing clearly fake asset URLs, processing local files normally, constructing the requested payload, and returning before persistent publication state or platform APIs.

Just name the guarantee honestly. This design is no-network, not no-filesystem. Once that boundary is explicit, it becomes straightforward to test, document, and tighten with a temporary cache when a completely clean preview is required.

AI assistance disclosure

AI assisted with outlining and drafting. Technical claims were checked against repository source, a recorded local experiment, and the official Forem API reference. No credentials, private paths, or production data are included.

Top comments (0)